2026-02-16 11:25:16 +00:00
import {
DeesElement ,
html ,
customElement ,
type TemplateResult ,
css ,
state ,
cssManager ,
} from '@design.estate/dees-element' ;
2026-04-08 08:24:55 +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-16 11:25:16 +00:00
import { type IStatsTile } from '@design.estate/dees-catalog' ;
declare global {
interface HTMLElementTagNameMap {
'ops-view-remoteingress' : OpsViewRemoteIngress ;
}
}
@customElement ( 'ops-view-remoteingress' )
export class OpsViewRemoteIngress extends DeesElement {
@state ( )
2026-03-26 07:40:56 +00:00
accessor riState : appstate.IRemoteIngressState = appstate . remoteIngressStatePart . getState ( ) ! ;
2026-02-16 11:25:16 +00:00
constructor ( ) {
super ( ) ;
2026-03-27 18:46:11 +00:00
const sub = appstate . remoteIngressStatePart . select ( ) . subscribe ( ( newState ) = > {
2026-02-16 11:25:16 +00:00
this . riState = newState ;
} ) ;
this . rxSubscriptions . push ( sub ) ;
}
async connectedCallback() {
await super . connectedCallback ( ) ;
await appstate . remoteIngressStatePart . dispatchAction ( appstate . fetchRemoteIngressAction , null ) ;
}
public static styles = [
cssManager . defaultStyles ,
viewHostCss ,
css `
.remoteIngressContainer {
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.connected {
background: ${ cssManager . bdTheme ( '#dcfce7' , '#14532d' ) } ;
color: ${ cssManager . bdTheme ( '#166534' , '#4ade80' ) } ;
}
.statusBadge.disconnected {
background: ${ cssManager . bdTheme ( '#fef2f2' , '#450a0a' ) } ;
color: ${ cssManager . bdTheme ( '#991b1b' , '#f87171' ) } ;
}
.statusBadge.disabled {
background: ${ cssManager . bdTheme ( '#f3f4f6' , '#374151' ) } ;
color: ${ cssManager . bdTheme ( '#6b7280' , '#9ca3af' ) } ;
}
.secretDialog {
padding: 16px;
background: ${ cssManager . bdTheme ( '#fffbeb' , '#1c1917' ) } ;
border: 1px solid ${ cssManager . bdTheme ( '#fbbf24' , '#92400e' ) } ;
border-radius: 8px;
margin-bottom: 16px;
}
.secretDialog code {
display: block;
padding: 8px 12px;
background: ${ cssManager . bdTheme ( '#1f2937' , '#111827' ) } ;
color: #10b981;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
word-break: break-all;
margin: 8px 0;
user-select: all;
}
.secretDialog .warning {
font-size: 12px;
color: ${ cssManager . bdTheme ( '#92400e' , '#fbbf24' ) } ;
margin-top: 8px;
}
.portsDisplay {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.portBadge {
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' ) } ;
}
2026-02-17 14:17:18 +00:00
.portBadge.manual {
background: ${ cssManager . bdTheme ( '#eff6ff' , '#172554' ) } ;
color: ${ cssManager . bdTheme ( '#1e40af' , '#60a5fa' ) } ;
}
.portBadge.derived {
background: ${ cssManager . bdTheme ( '#ecfdf5' , '#022c22' ) } ;
color: ${ cssManager . bdTheme ( '#047857' , '#34d399' ) } ;
border: 1px dashed ${ cssManager . bdTheme ( '#6ee7b7' , '#065f46' ) } ;
}
2026-02-16 11:25:16 +00:00
` ,
] ;
render ( ) : TemplateResult {
const totalEdges = this . riState . edges . length ;
const connectedEdges = this . riState . statuses . filter ( s = > s . connected ) . length ;
const disconnectedEdges = totalEdges - connectedEdges ;
const activeTunnels = this . riState . statuses . reduce ( ( sum , s ) = > sum + s . activeTunnels , 0 ) ;
const statsTiles : IStatsTile [ ] = [
{
id : 'totalEdges' ,
title : 'Total Edges' ,
type : 'number' ,
value : totalEdges ,
icon : 'lucide:server' ,
description : 'Registered edge nodes' ,
color : '#3b82f6' ,
} ,
{
id : 'connectedEdges' ,
title : 'Connected' ,
type : 'number' ,
value : connectedEdges ,
icon : 'lucide:link' ,
description : 'Currently connected edges' ,
color : '#10b981' ,
} ,
{
id : 'disconnectedEdges' ,
title : 'Disconnected' ,
type : 'number' ,
value : disconnectedEdges ,
icon : 'lucide:unlink' ,
description : 'Offline edge nodes' ,
color : disconnectedEdges > 0 ? '#ef4444' : '#6b7280' ,
} ,
{
id : 'activeTunnels' ,
title : 'Active Tunnels' ,
type : 'number' ,
value : activeTunnels ,
icon : 'lucide:cable' ,
description : 'Active client connections' ,
color : '#8b5cf6' ,
} ,
] ;
return html `
2026-04-08 11:08:18 +00:00
<dees-heading level="3">Remote Ingress</dees-heading>
2026-02-16 11:25:16 +00:00
2026-02-18 18:47:18 +00:00
${ this . riState . newEdgeId ? html `
2026-02-16 11:25:16 +00:00
<div class="secretDialog">
2026-02-18 18:47:18 +00:00
<strong>Edge created successfully!</strong>
<div class="warning">Copy the connection token now. Use it with edge.start({ token: '...' }).</div>
2026-02-16 11:25:16 +00:00
<dees-button
2026-02-18 18:47:18 +00:00
@click= ${ async ( ) = > {
const { DeesToast } = await import('@design.estate/dees-catalog');
try {
2026-03-26 07:40:56 +00:00
const response = await appstate.fetchConnectionToken(this.riState.newEdgeId!);
2026-02-18 18:47:18 +00:00
if (response.success && response.token) {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(response.token);
} else {
const textarea = document.createElement('textarea');
textarea.value = response.token;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
DeesToast.show({ message: 'Connection token copied!', type: 'success', duration: 3000 });
} else {
DeesToast.show({ message: response.message || 'Failed to get token', type: 'error', duration: 4000 });
}
2026-03-26 07:40:56 +00:00
} catch (err: unknown) {
DeesToast.show({ message: ` Failed : $ { ( err as Error ) . message } ` , type : 'error' , duration : 4000 } ) ;
2026-02-18 18:47:18 +00:00
}
} }
> Copy Connection Token < / d e e s - b u t t o n >
< dees - button
@ click = $ { ( ) = > appstate . remoteIngressStatePart . dispatchAction ( appstate . clearNewEdgeIdAction , null ) }
2026-02-16 11:25:16 +00:00
> Dismiss < / d e e s - b u t t o n >
< / div >
` : ''}
<div class="remoteIngressContainer">
<dees-statsgrid .tiles= ${ statsTiles } ></dees-statsgrid>
<dees-table
.heading1= ${ 'Edge Nodes' }
.heading2= ${ 'Manage remote ingress edge registrations' }
.data= ${ this . riState . edges }
2026-04-08 15:26:12 +00:00
.rowKey= ${ 'id' }
.highlightUpdates= ${ 'flash' }
2026-04-08 07:11:21 +00:00
.showColumnFilters= ${ true }
2026-02-16 11:25:16 +00:00
.displayFunction= ${ ( edge : interfaces.data.IRemoteIngress ) = > ( {
name : edge.name ,
status : this.getEdgeStatusHtml ( edge ) ,
publicIp : this.getEdgePublicIp ( edge . id ) ,
2026-02-17 11:56:54 +00:00
ports : this.getPortsHtml ( edge ) ,
2026-02-16 11:25:16 +00:00
tunnels : this.getEdgeTunnelCount ( edge . id ) ,
lastHeartbeat : this.getLastHeartbeat ( edge . id ) ,
} )}
.dataActions= ${ [
2026-02-16 22:42:30 +00:00
{
name : 'Create Edge Node' ,
iconName : 'lucide:plus' ,
type : [ 'header' ] ,
actionFunc : async ( ) = > {
const { DeesModal } = await import('@design.estate/dees-catalog');
2026-02-17 11:56:54 +00:00
const modal = await DeesModal.createAndShow({
2026-02-16 22:42:30 +00:00
heading: 'Create Edge Node',
content: html `
< dees - form >
< dees - input - text .key = $ { 'name' } .label = $ { 'Name' } .required = $ { true } > < / d e e s - i n p u t - t e x t >
2026-04-12 19:42:07 +00:00
< dees - input - text .key = $ { 'listenPorts' } .label = $ { 'Manual Ports' } .description = $ { 'Comma-separated port numbers, optional' } > < / d e e s - i n p u t - t e x t >
2026-02-17 14:17:18 +00:00
< dees - input - checkbox .key = $ { 'autoDerivePorts' } .label = $ { 'Auto-derive ports from routes' } .value = $ { true } > < / d e e s - i n p u t - c h e c k b o x >
2026-04-12 19:42:07 +00:00
< dees - input - text .key = $ { 'tags' } .label = $ { 'Tags' } .description = $ { 'Comma-separated, optional' } > < / d e e s - i n p u t - t e x t >
2026-02-16 22:42:30 +00:00
< / d e e s - f o r m >
` ,
2026-02-17 11:56:54 +00:00
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
2026-02-16 22:42:30 +00:00
{
2026-02-17 11:56:54 +00:00
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();
const name = formData.name;
if (!name) return;
const portsStr = formData.listenPorts?.trim();
const listenPorts = portsStr
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
: undefined;
2026-02-17 14:17:18 +00:00
const autoDerivePorts = formData.autoDerivePorts !== false;
2026-02-17 11:56:54 +00:00
const tags = formData.tags
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: undefined;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.createRemoteIngressAction,
2026-02-17 14:17:18 +00:00
{ name, listenPorts, autoDerivePorts, tags },
2026-02-17 11:56:54 +00:00
);
await modalArg.destroy();
},
2026-02-16 22:42:30 +00:00
},
2026-02-17 11:56:54 +00:00
],
});
},
},
{
name: 'Enable',
iconName: 'lucide:play',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.toggleRemoteIngressAction,
{ id: edge.id, enabled: true },
);
},
},
{
name: 'Disable',
iconName: 'lucide:pause',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.toggleRemoteIngressAction,
{ id: edge.id, enabled: false },
);
2026-02-16 22:42:30 +00:00
},
},
2026-02-17 14:17:18 +00:00
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: ` Edit Edge : $ { edge . name } ` ,
content: html `
< dees - form >
< dees - input - text .key = $ { 'name' } .label = $ { 'Name' } .value = $ { edge.name } > < / d e e s - i n p u t - t e x t >
2026-04-12 19:42:07 +00:00
< dees - input - text .key = $ { 'listenPorts' } .label = $ { 'Manual Ports' } .description = $ { 'Comma-separated port numbers' } .value = $ { ( edge.listenPorts | | [ ] ) .join ( ', ' ) } > < / d e e s - i n p u t - t e x t >
2026-02-17 14:17:18 +00:00
< dees - input - checkbox .key = $ { 'autoDerivePorts' } .label = $ { 'Auto-derive ports from routes' } .value = $ { edge.autoDerivePorts ! = = false } > < / d e e s - i n p u t - c h e c k b o x >
2026-04-12 19:42:07 +00:00
< dees - input - text .key = $ { 'tags' } .label = $ { 'Tags' } .description = $ { 'Comma-separated' } .value = $ { ( edge.tags | | [ ] ) .join ( ', ' ) } > < / d e e s - i n p u t - t e x t >
2026-02-17 14:17:18 +00:00
< / d e e s - f o r m >
` ,
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();
const portsStr = formData.listenPorts?.trim();
const listenPorts = portsStr
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
: [];
const autoDerivePorts = formData.autoDerivePorts !== false;
const tags = formData.tags
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: [];
await appstate.remoteIngressStatePart.dispatchAction(
appstate.updateRemoteIngressAction,
{
id: edge.id,
name: formData.name || edge.name,
listenPorts,
autoDerivePorts,
tags,
},
);
await modalArg.destroy();
},
},
],
});
},
},
2026-02-16 11:25:16 +00:00
{
name: 'Regenerate Secret',
iconName: 'lucide:key',
2026-02-17 11:56:54 +00:00
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
2026-02-16 11:25:16 +00:00
await appstate.remoteIngressStatePart.dispatchAction(
appstate.regenerateRemoteIngressSecretAction,
edge.id,
);
},
},
2026-02-18 06:05:46 +00:00
{
name: 'Copy Token',
2026-02-18 18:47:18 +00:00
iconName: 'lucide:ClipboardCopy',
2026-02-18 06:05:46 +00:00
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
const { DeesToast } = await import('@design.estate/dees-catalog');
try {
const response = await appstate.fetchConnectionToken(edge.id);
if (response.success && response.token) {
2026-02-18 18:47:18 +00:00
// Use clipboard API with fallback for non-HTTPS contexts
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(response.token);
} else {
const textarea = document.createElement('textarea');
textarea.value = response.token;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
2026-02-18 06:05:46 +00:00
DeesToast.show({ message: ` Connection token copied for $ { edge . name } ` , type: 'success', duration: 3000 });
} else {
DeesToast.show({ message: response.message || 'Failed to get token', type: 'error', duration: 4000 });
}
2026-03-26 07:40:56 +00:00
} catch (err: unknown) {
DeesToast.show({ message: ` Failed : $ { ( err as Error ) . message } ` , type: 'error', duration: 4000 });
2026-02-18 06:05:46 +00:00
}
},
},
2026-02-16 11:25:16 +00:00
{
name: 'Delete',
iconName: 'lucide:trash2',
2026-02-17 11:56:54 +00:00
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
2026-02-16 11:25:16 +00:00
await appstate.remoteIngressStatePart.dispatchAction(
appstate.deleteRemoteIngressAction,
edge.id,
);
},
},
]}
></dees-table>
</div>
` ;
}
private getEdgeStatus ( edgeId : string ) : interfaces . data . IRemoteIngressStatus | undefined {
return this . riState . statuses . find ( s = > s . edgeId === edgeId ) ;
}
private getEdgeStatusHtml ( edge : interfaces.data.IRemoteIngress ) : TemplateResult {
if ( ! edge . enabled ) {
return html ` <span class="statusBadge disabled">Disabled</span> ` ;
}
const status = this . getEdgeStatus ( edge . id ) ;
if ( status ? . connected ) {
return html ` <span class="statusBadge connected">Connected</span> ` ;
}
return html ` <span class="statusBadge disconnected">Disconnected</span> ` ;
}
private getEdgePublicIp ( edgeId : string ) : string {
const status = this . getEdgeStatus ( edgeId ) ;
return status ? . publicIp || '-' ;
}
2026-02-17 11:56:54 +00:00
private getPortsHtml ( edge : interfaces.data.IRemoteIngress ) : TemplateResult {
2026-02-17 14:17:18 +00:00
const manualPorts = edge . manualPorts || [ ] ;
const derivedPorts = edge . derivedPorts || [ ] ;
if ( manualPorts . length === 0 && derivedPorts . length === 0 ) {
2026-02-17 11:56:54 +00:00
return html ` <span style="color: var(--text-muted, #6b7280); font-size: 12px;">none</span> ` ;
}
2026-02-17 14:17:18 +00:00
return html ` <div class="portsDisplay"> ${ manualPorts . map ( p = > html ` <span class="portBadge manual"> ${ p } </span> ` ) } ${ derivedPorts . map ( p = > html ` <span class="portBadge derived"> ${ p } </span> ` ) } ${ derivedPorts . length > 0 ? html ` <span style="font-size: 11px; color: var(--text-muted, #6b7280); align-self: center;">(auto)</span> ` : '' } </div> ` ;
2026-02-16 11:25:16 +00:00
}
private getEdgeTunnelCount ( edgeId : string ) : number {
const status = this . getEdgeStatus ( edgeId ) ;
return status ? . activeTunnels || 0 ;
}
private getLastHeartbeat ( edgeId : string ) : string {
const status = this . getEdgeStatus ( edgeId ) ;
if ( ! status ? . lastHeartbeat ) return '-' ;
const ago = Date . now ( ) - status . lastHeartbeat ;
if ( ago < 60000 ) return ` ${ Math . floor ( ago / 1000 ) } s ago ` ;
if ( ago < 3600000 ) return ` ${ Math . floor ( ago / 60000 ) } m ago ` ;
return ` ${ Math . floor ( ago / 3600000 ) } h ago ` ;
}
}