import { DeesElement, html, customElement, type TemplateResult, css, state, cssManager, } from '@design.estate/dees-element'; import * as plugins from '../plugins.js'; 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-vpn': OpsViewVpn; } } @customElement('ops-view-vpn') export class OpsViewVpn extends DeesElement { @state() accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!; constructor() { super(); const sub = appstate.vpnStatePart.select().subscribe((newState) => { this.vpnState = newState; }); this.rxSubscriptions.push(sub); } async connectedCallback() { await super.connectedCallback(); await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null); } public static styles = [ cssManager.defaultStyles, viewHostCss, css` .vpnContainer { 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.enabled { background: ${cssManager.bdTheme('#dcfce7', '#14532d')}; color: ${cssManager.bdTheme('#166534', '#4ade80')}; } .statusBadge.disabled { background: ${cssManager.bdTheme('#fef2f2', '#450a0a')}; color: ${cssManager.bdTheme('#991b1b', '#f87171')}; } .configDialog { padding: 16px; background: ${cssManager.bdTheme('#fffbeb', '#1c1917')}; border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')}; border-radius: 8px; margin-bottom: 16px; } .configDialog pre { display: block; padding: 12px; background: ${cssManager.bdTheme('#1f2937', '#111827')}; color: #10b981; border-radius: 4px; font-family: monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; margin: 8px 0; user-select: all; max-height: 300px; overflow-y: auto; } .configDialog .warning { font-size: 12px; color: ${cssManager.bdTheme('#92400e', '#fbbf24')}; margin-top: 8px; } .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; } .serverInfo { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; padding: 16px; background: ${cssManager.bdTheme('#f9fafb', '#111827')}; border-radius: 8px; border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#1f2937')}; } .serverInfo .infoItem { display: flex; flex-direction: column; gap: 4px; } .serverInfo .infoLabel { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; } .serverInfo .infoValue { font-size: 14px; font-family: monospace; color: ${cssManager.bdTheme('#111827', '#f9fafb')}; } `, ]; /** Look up connected client info by clientId or assignedIp */ private getConnectedInfo(client: interfaces.data.IVpnClient): interfaces.data.IVpnConnectedClient | undefined { return this.vpnState.connectedClients?.find( c => c.clientId === client.clientId || (client.assignedIp && c.assignedIp === client.assignedIp) ); } render(): TemplateResult { const status = this.vpnState.status; const clients = this.vpnState.clients; const connectedClients = this.vpnState.connectedClients || []; const connectedCount = connectedClients.length; const totalClients = clients.length; const enabledClients = clients.filter(c => c.enabled).length; const statsTiles: IStatsTile[] = [ { id: 'totalClients', title: 'Total Clients', type: 'number', value: totalClients, icon: 'lucide:users', description: 'Registered VPN clients', color: '#3b82f6', }, { id: 'connectedClients', title: 'Connected', type: 'number', value: connectedCount, icon: 'lucide:link', description: 'Currently connected', color: '#10b981', }, { id: 'enabledClients', title: 'Enabled', type: 'number', value: enabledClients, icon: 'lucide:shieldCheck', description: 'Active client registrations', color: '#8b5cf6', }, { id: 'serverStatus', title: 'Server', type: 'text', value: status?.running ? 'Running' : 'Stopped', icon: 'lucide:server', description: status?.running ? 'Active' : 'VPN server not running', color: status?.running ? '#10b981' : '#ef4444', }, ]; return html` VPN
${this.vpnState.newClientConfig ? html`
Client created successfully!
Copy the WireGuard config now. It contains private keys that won't be shown again.
${this.vpnState.newClientConfig}
{ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { await navigator.clipboard.writeText(this.vpnState.newClientConfig!); } const { DeesToast } = await import('@design.estate/dees-catalog'); DeesToast.createAndShow({ message: 'Config copied to clipboard', type: 'success', duration: 3000 }); }} >Copy to Clipboard { const blob = new Blob([this.vpnState.newClientConfig!], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'wireguard.conf'; a.click(); URL.revokeObjectURL(url); }} >Download .conf { const dataUrl = await plugins.qrcode.toDataURL( this.vpnState.newClientConfig!, { width: 400, margin: 2 } ); const { DeesModal } = await import('@design.estate/dees-catalog'); DeesModal.createAndShow({ heading: 'WireGuard QR Code', content: html`

Scan with the WireGuard app on your phone

`, menuOptions: [ { name: 'Close', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() }, ], }); }} >Show QR Code
appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)} >Dismiss
` : ''} ${status ? html`
Subnet ${status.subnet}
WireGuard Port ${status.wgListenPort}
${status.serverPublicKeys ? html`
WG Public Key ${status.serverPublicKeys.wgPublicKey}
` : ''}
` : ''} { const conn = this.getConnectedInfo(client); let statusHtml; if (!client.enabled) { statusHtml = html`disabled`; } else if (conn) { const since = new Date(conn.connectedSince).toLocaleString(); statusHtml = html`connected`; } else { statusHtml = html`offline`; } return { 'Client ID': client.clientId, 'Status': statusHtml, 'VPN IP': client.assignedIp || '-', 'Tags': client.serverDefinedClientTags?.length ? html`${client.serverDefinedClientTags.map(t => html`${t}`)}` : '-', 'Description': client.description || '-', 'Created': new Date(client.createdAt).toLocaleDateString(), }; }} .dataActions=${[ { name: 'Create Client', iconName: 'lucide:plus', type: ['header'], actionFunc: async () => { const { DeesModal } = await import('@design.estate/dees-catalog'); await DeesModal.createAndShow({ heading: 'Create VPN Client', content: html` `, 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 data = await form.collectFormData(); if (!data.clientId) return; const serverDefinedClientTags = data.tags ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined; await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, { clientId: data.clientId, description: data.description || undefined, serverDefinedClientTags, }); await modalArg.destroy(); }, }, ], }); }, }, { name: 'Detail', iconName: 'lucide:info', type: ['doubleClick'], actionFunc: async (actionData: any) => { const client = actionData.item as interfaces.data.IVpnClient; const conn = this.getConnectedInfo(client); const { DeesModal } = await import('@design.estate/dees-catalog'); // Fetch telemetry on-demand let telemetryHtml = html`

Loading telemetry...

`; try { const request = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_GetVpnClientTelemetry >('/typedrequest', 'getVpnClientTelemetry'); const response = await request.fire({ identity: appstate.loginStatePart.getState()!.identity!, clientId: client.clientId, }); const t = response.telemetry; if (t) { const formatBytes = (b: number) => b > 1048576 ? `${(b / 1048576).toFixed(1)} MB` : b > 1024 ? `${(b / 1024).toFixed(1)} KB` : `${b} B`; telemetryHtml = html`
Bytes Sent${formatBytes(t.bytesSent)}
Bytes Received${formatBytes(t.bytesReceived)}
Keepalives${t.keepalivesReceived}
Last Keepalive${t.lastKeepaliveAt ? new Date(t.lastKeepaliveAt).toLocaleString() : '-'}
Packets Dropped${t.packetsDropped}
`; } else { telemetryHtml = html`

No telemetry available (client not connected)

`; } } catch { telemetryHtml = html`

Telemetry unavailable

`; } DeesModal.createAndShow({ heading: `Client: ${client.clientId}`, content: html`
Client ID${client.clientId}
VPN IP${client.assignedIp || '-'}
Status${!client.enabled ? 'Disabled' : conn ? 'Connected' : 'Offline'}
${conn ? html`
Connected Since${new Date(conn.connectedSince).toLocaleString()}
Transport${conn.transport}
` : ''}
Description${client.description || '-'}
Tags${client.serverDefinedClientTags?.join(', ') || '-'}
Created${new Date(client.createdAt).toLocaleString()}
Updated${new Date(client.updatedAt).toLocaleString()}

Telemetry

${telemetryHtml} `, menuOptions: [ { name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() }, ], }); }, }, { name: 'Enable', iconName: 'lucide:power', type: ['contextmenu', 'inRow'], actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled, actionFunc: async (actionData: any) => { const client = actionData.item as interfaces.data.IVpnClient; await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, { clientId: client.clientId, enabled: true, }); }, }, { name: 'Disable', iconName: 'lucide:power', type: ['contextmenu', 'inRow'], actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled, actionFunc: async (actionData: any) => { const client = actionData.item as interfaces.data.IVpnClient; await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, { clientId: client.clientId, enabled: false, }); }, }, { name: 'Export Config', iconName: 'lucide:download', type: ['contextmenu', 'inRow'], actionFunc: async (actionData: any) => { const client = actionData.item as interfaces.data.IVpnClient; const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); const exportConfig = async (format: 'wireguard' | 'smartvpn') => { try { const request = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_ExportVpnClientConfig >('/typedrequest', 'exportVpnClientConfig'); const response = await request.fire({ identity: appstate.loginStatePart.getState()!.identity!, clientId: client.clientId, format, }); if (response.success && response.config) { const ext = format === 'wireguard' ? 'conf' : 'json'; const blob = new Blob([response.config], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${client.clientId}.${ext}`; a.click(); URL.revokeObjectURL(url); DeesToast.createAndShow({ message: `${format} config downloaded`, type: 'success', duration: 3000 }); } else { DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 }); } } catch (err: any) { DeesToast.createAndShow({ message: err.message || 'Export failed', type: 'error', duration: 5000 }); } }; const showQrCode = async () => { try { const request = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_ExportVpnClientConfig >('/typedrequest', 'exportVpnClientConfig'); const response = await request.fire({ identity: appstate.loginStatePart.getState()!.identity!, clientId: client.clientId, format: 'wireguard', }); if (response.success && response.config) { const dataUrl = await plugins.qrcode.toDataURL( response.config, { width: 400, margin: 2 } ); DeesModal.createAndShow({ heading: `QR Code: ${client.clientId}`, content: html`

Scan with the WireGuard app on your phone

`, menuOptions: [ { name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() }, ], }); } else { DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 }); } } catch (err: any) { DeesToast.createAndShow({ message: err.message || 'QR generation failed', type: 'error', duration: 5000 }); } }; DeesModal.createAndShow({ heading: `Export Config: ${client.clientId}`, content: html`

Choose a config format to download.

`, menuOptions: [ { name: 'WireGuard (.conf)', iconName: 'lucide:shield', action: async (modalArg: any) => { await modalArg.destroy(); await exportConfig('wireguard'); }, }, { name: 'SmartVPN (.json)', iconName: 'lucide:braces', action: async (modalArg: any) => { await modalArg.destroy(); await exportConfig('smartvpn'); }, }, { name: 'QR Code (WireGuard)', iconName: 'lucide:qr-code', action: async (modalArg: any) => { await modalArg.destroy(); await showQrCode(); }, }, { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy(), }, ], }); }, }, { name: 'Edit', iconName: 'lucide:pencil', type: ['contextmenu', 'inRow'], actionFunc: async (actionData: any) => { const client = actionData.item as interfaces.data.IVpnClient; const { DeesModal } = await import('@design.estate/dees-catalog'); const currentDescription = client.description ?? ''; const currentTags = client.serverDefinedClientTags?.join(', ') ?? ''; DeesModal.createAndShow({ heading: `Edit: ${client.clientId}`, content: html` `, 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 data = await form.collectFormData(); const serverDefinedClientTags = data.tags ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : []; await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, { clientId: client.clientId, description: data.description || undefined, serverDefinedClientTags, }); await modalArg.destroy(); }, }, ], }); }, }, { name: 'Rotate Keys', iconName: 'lucide:rotate-cw', type: ['contextmenu'], actionFunc: async (actionData: any) => { const client = actionData.item as interfaces.data.IVpnClient; const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); DeesModal.createAndShow({ heading: 'Rotate Client Keys', content: html`

Generate new keys for "${client.clientId}"? The old keys will be invalidated and the client will need the new config to reconnect.

`, menuOptions: [ { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() }, { name: 'Rotate', iconName: 'lucide:rotate-cw', action: async (modalArg: any) => { try { const request = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_RotateVpnClientKey >('/typedrequest', 'rotateVpnClientKey'); const response = await request.fire({ identity: appstate.loginStatePart.getState()!.identity!, clientId: client.clientId, }); if (response.success && response.wireguardConfig) { appstate.vpnStatePart.setState({ ...appstate.vpnStatePart.getState()!, newClientConfig: response.wireguardConfig, }); } await modalArg.destroy(); } catch (err: any) { DeesToast.createAndShow({ message: err.message || 'Rotate failed', type: 'error', duration: 5000 }); } }, }, ], }); }, }, { name: 'Delete', iconName: 'lucide:trash2', type: ['contextmenu'], actionFunc: async (actionData: any) => { const client = actionData.item as interfaces.data.IVpnClient; const { DeesModal } = await import('@design.estate/dees-catalog'); DeesModal.createAndShow({ heading: 'Delete VPN Client', content: html`

Are you sure you want to delete client "${client.clientId}"?

`, menuOptions: [ { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() }, { name: 'Delete', iconName: 'lucide:trash2', action: async (modalArg: any) => { await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId); await modalArg.destroy(); }, }, ], }); }, }, ]} >
`; } }