feat(web-ui): reorganize network and security views into tabbed subviews with route-aware navigation

This commit is contained in:
2026-04-08 07:45:26 +00:00
parent 1b37944aab
commit 2b76e05a40
22 changed files with 973 additions and 610 deletions

View File

@@ -0,0 +1,6 @@
export * from './ops-view-network.js';
export * from './ops-view-network-activity.js';
export * from './ops-view-routes.js';
export * from './ops-view-sourceprofiles.js';
export * from './ops-view-networktargets.js';
export * from './ops-view-targetprofiles.js';

View File

@@ -0,0 +1,811 @@
import { DeesElement, property, 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 { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-network-activity': OpsViewNetworkActivity;
}
}
interface INetworkRequest {
id: string;
timestamp: number;
method: string;
url: string;
hostname: string;
port: number;
protocol: 'http' | 'https' | 'tcp' | 'udp';
statusCode?: number;
duration: number;
bytesIn: number;
bytesOut: number;
remoteIp: string;
route?: string;
}
@customElement('ops-view-network-activity')
export class OpsViewNetworkActivity extends DeesElement {
/** How far back the traffic chart shows */
private static readonly CHART_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
/** How often a new data point is added */
private static readonly UPDATE_INTERVAL_MS = 1000; // 1 second
/** Derived: max data points the buffer holds */
private static readonly MAX_DATA_POINTS = OpsViewNetworkActivity.CHART_WINDOW_MS / OpsViewNetworkActivity.UPDATE_INTERVAL_MS;
@state()
accessor statsState = appstate.statsStatePart.getState()!;
@state()
accessor networkState = appstate.networkStatePart.getState()!;
@state()
accessor networkRequests: INetworkRequest[] = [];
@state()
accessor trafficDataIn: Array<{ x: string | number; y: number }> = [];
@state()
accessor trafficDataOut: Array<{ x: string | number; y: number }> = [];
// Track if we need to update the chart to avoid unnecessary re-renders
private lastChartUpdate = 0;
private chartUpdateThreshold = OpsViewNetworkActivity.UPDATE_INTERVAL_MS; // Minimum ms between chart updates
private trafficUpdateTimer: any = null;
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
private historyLoaded = false; // Whether server-side throughput history has been loaded
private visibilityHandler: (() => void) | null = null;
constructor() {
super();
this.subscribeToStateParts();
this.initializeTrafficData();
this.updateNetworkData();
this.startTrafficUpdateTimer();
}
async connectedCallback() {
await super.connectedCallback();
// Pause/resume traffic timer when tab visibility changes
this.visibilityHandler = () => {
if (document.hidden) {
this.stopTrafficUpdateTimer();
} else {
this.startTrafficUpdateTimer();
}
};
document.addEventListener('visibilitychange', this.visibilityHandler);
// When network view becomes visible, ensure we fetch network data
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
}
async disconnectedCallback() {
await super.disconnectedCallback();
this.stopTrafficUpdateTimer();
if (this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
}
private subscribeToStateParts() {
// Subscribe and track unsubscribe functions
const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => {
this.statsState = state;
this.updateNetworkData();
});
this.rxSubscriptions.push(statsUnsubscribe);
const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
this.networkState = state;
this.updateNetworkData();
});
this.rxSubscriptions.push(networkUnsubscribe);
}
private initializeTrafficData() {
const now = Date.now();
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetworkActivity;
// Initialize with empty data points for both in and out
const emptyData = Array.from({ length: MAX_DATA_POINTS }, (_, i) => {
const time = now - ((MAX_DATA_POINTS - 1 - i) * UPDATE_INTERVAL_MS);
return {
x: new Date(time).toISOString(),
y: 0,
};
});
this.trafficDataIn = [...emptyData];
this.trafficDataOut = emptyData.map(point => ({ ...point }));
}
/**
* Load server-side throughput history into the chart.
* Called once when history data first arrives from the Rust engine.
* This pre-populates the chart so users see historical data immediately
* instead of starting from all zeros.
*/
private loadThroughputHistory() {
const history = this.networkState.throughputHistory;
if (!history || history.length === 0) return;
this.historyLoaded = true;
// Convert history points to chart data format (bytes/sec → Mbit/s)
const historyIn = history.map(p => ({
x: new Date(p.timestamp).toISOString(),
y: Math.round((p.in * 8) / 1000000 * 10) / 10,
}));
const historyOut = history.map(p => ({
x: new Date(p.timestamp).toISOString(),
y: Math.round((p.out * 8) / 1000000 * 10) / 10,
}));
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetworkActivity;
// Use history as the chart data, keeping the most recent points within the window
const sliceStart = Math.max(0, historyIn.length - MAX_DATA_POINTS);
this.trafficDataIn = historyIn.slice(sliceStart);
this.trafficDataOut = historyOut.slice(sliceStart);
// If fewer than MAX_DATA_POINTS, pad the front with zeros
if (this.trafficDataIn.length < MAX_DATA_POINTS) {
const now = Date.now();
const padCount = MAX_DATA_POINTS - this.trafficDataIn.length;
const firstTimestamp = this.trafficDataIn.length > 0
? new Date(this.trafficDataIn[0].x).getTime()
: now;
const padIn = Array.from({ length: padCount }, (_, i) => ({
x: new Date(firstTimestamp - ((padCount - i) * UPDATE_INTERVAL_MS)).toISOString(),
y: 0,
}));
const padOut = padIn.map(p => ({ ...p }));
this.trafficDataIn = [...padIn, ...this.trafficDataIn];
this.trafficDataOut = [...padOut, ...this.trafficDataOut];
}
}
public static styles = [
cssManager.defaultStyles,
css`
:host { display: block; }
.networkContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.protocolBadge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.protocolBadge.http {
background: ${cssManager.bdTheme('#e3f2fd', '#1a2c3a')};
color: ${cssManager.bdTheme('#1976d2', '#5a9fd4')};
}
.protocolBadge.https {
background: ${cssManager.bdTheme('#e8f5e9', '#1a3a1a')};
color: ${cssManager.bdTheme('#388e3c', '#66bb6a')};
}
.protocolBadge.tcp {
background: ${cssManager.bdTheme('#fff3e0', '#3a2a1a')};
color: ${cssManager.bdTheme('#f57c00', '#ff9933')};
}
.protocolBadge.smtp {
background: ${cssManager.bdTheme('#f3e5f5', '#2a1a3a')};
color: ${cssManager.bdTheme('#7b1fa2', '#ba68c8')};
}
.protocolBadge.dns {
background: ${cssManager.bdTheme('#e0f2f1', '#1a3a3a')};
color: ${cssManager.bdTheme('#00796b', '#4db6ac')};
}
.protocolBadge.h1 {
background: ${cssManager.bdTheme('#e3f2fd', '#1a2c3a')};
color: ${cssManager.bdTheme('#1976d2', '#5a9fd4')};
}
.protocolBadge.h2 {
background: ${cssManager.bdTheme('#e8f5e9', '#1a3a1a')};
color: ${cssManager.bdTheme('#388e3c', '#66bb6a')};
}
.protocolBadge.h3 {
background: ${cssManager.bdTheme('#f3e5f5', '#2a1a3a')};
color: ${cssManager.bdTheme('#7b1fa2', '#ba68c8')};
}
.protocolBadge.unknown {
background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')};
color: ${cssManager.bdTheme('#757575', '#999999')};
}
.suppressionBadge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#fff3e0', '#3a2a1a')};
color: ${cssManager.bdTheme('#f57c00', '#ff9933')};
margin-left: 4px;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.statusBadge.success {
background: ${cssManager.bdTheme('#e8f5e9', '#1a3a1a')};
color: ${cssManager.bdTheme('#388e3c', '#66bb6a')};
}
.statusBadge.error {
background: ${cssManager.bdTheme('#ffebee', '#3a1a1a')};
color: ${cssManager.bdTheme('#d32f2f', '#ff6666')};
}
.statusBadge.warning {
background: ${cssManager.bdTheme('#fff3e0', '#3a2a1a')};
color: ${cssManager.bdTheme('#f57c00', '#ff9933')};
}
.protocolChartGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
`,
];
public render() {
return html`
<dees-heading level="hr">Network Activity</dees-heading>
<div class="networkContainer">
<!-- Stats Grid -->
${this.renderNetworkStats()}
<!-- Traffic Chart -->
<dees-chart-area
.label=${'Network Traffic'}
.series=${[
{
name: 'Inbound',
data: this.trafficDataIn,
color: '#22c55e',
},
{
name: 'Outbound',
data: this.trafficDataOut,
color: '#8b5cf6',
}
]}
.realtimeMode=${true}
.rollingWindow=${OpsViewNetworkActivity.CHART_WINDOW_MS}
.yAxisFormatter=${(val: number) => `${val} Mbit/s`}
></dees-chart-area>
<!-- Protocol Distribution Charts -->
${this.renderProtocolCharts()}
<!-- Top IPs Section -->
${this.renderTopIPs()}
<!-- Backend Protocols Section -->
${this.renderBackendProtocols()}
<!-- Requests Table -->
<dees-table
.data=${this.networkRequests}
.displayFunction=${(req: INetworkRequest) => ({
Time: new Date(req.timestamp).toLocaleTimeString(),
Protocol: html`<span class="protocolBadge ${req.protocol}">${req.protocol.toUpperCase()}</span>`,
Method: req.method,
'Host:Port': `${req.hostname}:${req.port}`,
Path: this.truncateUrl(req.url),
Status: this.renderStatus(req.statusCode),
Duration: `${req.duration}ms`,
'In/Out': `${this.formatBytes(req.bytesIn)} / ${this.formatBytes(req.bytesOut)}`,
'Remote IP': req.remoteIp,
})}
.dataActions=${[
{
name: 'View Details',
iconName: 'fa:magnifyingGlass',
type: ['inRow', 'doubleClick', 'contextmenu'],
actionFunc: async (actionData) => {
await this.showRequestDetails(actionData.item);
}
}
]}
heading1="Recent Network Activity"
heading2="Recent network requests"
searchable
.pagination=${true}
.paginationSize=${50}
dataName="request"
></dees-table>
</div>
`;
}
private async showRequestDetails(request: INetworkRequest) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Request Details',
content: html`
<div style="padding: 20px;">
<dees-dataview-codebox
.heading=${'Request Information'}
progLang="json"
.codeToDisplay=${JSON.stringify({
id: request.id,
timestamp: new Date(request.timestamp).toISOString(),
protocol: request.protocol,
method: request.method,
url: request.url,
hostname: request.hostname,
port: request.port,
statusCode: request.statusCode,
duration: `${request.duration}ms`,
bytesIn: request.bytesIn,
bytesOut: request.bytesOut,
remoteIp: request.remoteIp,
route: request.route,
}, null, 2)}
></dees-dataview-codebox>
</div>
`,
menuOptions: [
{
name: 'Copy Request ID',
iconName: 'lucide:Copy',
action: async () => {
await navigator.clipboard.writeText(request.id);
}
}
]
});
}
private renderStatus(statusCode?: number): TemplateResult {
if (!statusCode) {
return html`<span class="statusBadge warning">N/A</span>`;
}
const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' :
statusCode >= 400 ? 'error' : 'warning';
return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`;
}
private truncateUrl(url: string, maxLength = 50): string {
if (url.length <= maxLength) return url;
return url.substring(0, maxLength - 3) + '...';
}
private formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toFixed(0);
}
private formatBytes(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
private formatBitsPerSecond(bytesPerSecond: number): string {
const bitsPerSecond = bytesPerSecond * 8; // Convert bytes to bits
const units = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s'];
let size = bitsPerSecond;
let unitIndex = 0;
while (size >= 1000 && unitIndex < units.length - 1) {
size /= 1000; // Use 1000 for bits (not 1024)
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
private calculateThroughput(): { in: number; out: number } {
// Use real throughput data from network state
return {
in: this.networkState.throughputRate.bytesInPerSecond,
out: this.networkState.throughputRate.bytesOutPerSecond,
};
}
private renderNetworkStats(): TemplateResult {
// Use server-side requests/sec from SmartProxy's Rust engine
const reqPerSec = this.networkState.requestsPerSecond || 0;
const throughput = this.calculateThroughput();
const activeConnections = this.statsState.serverStats?.activeConnections || 0;
// Build trend data from pre-computed history (mutated in updateNetworkData, not here)
const trendData = [...this.requestsPerSecHistory];
while (trendData.length < 20) {
trendData.unshift(0);
}
const tiles: IStatsTile[] = [
{
id: 'connections',
title: 'Active Connections',
value: activeConnections,
type: 'number',
icon: 'lucide:Plug',
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`,
actions: [
{
name: 'View Details',
iconName: 'fa:magnifyingGlass',
action: async () => {
},
},
],
},
{
id: 'requests',
title: 'Requests/sec',
value: reqPerSec,
type: 'trend',
icon: 'lucide:ChartLine',
color: '#3b82f6',
trendData: trendData,
description: `Total: ${this.formatNumber(this.networkState.requestsTotal || 0)} requests`,
},
{
id: 'throughputIn',
title: 'Throughput In',
value: this.formatBitsPerSecond(throughput.in),
unit: '',
type: 'number',
icon: 'lucide:Download',
color: '#22c55e',
description: `Total: ${this.formatBytes(this.networkState.totalBytes?.in || 0)}`,
},
{
id: 'throughputOut',
title: 'Throughput Out',
value: this.formatBitsPerSecond(throughput.out),
unit: '',
type: 'number',
icon: 'lucide:Upload',
color: '#8b5cf6',
description: `Total: ${this.formatBytes(this.networkState.totalBytes?.out || 0)}`,
},
];
return html`
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
`;
}
private renderProtocolCharts(): TemplateResult {
const fp = this.networkState.frontendProtocols;
const bp = this.networkState.backendProtocols;
const protoColors: Record<string, string> = {
'HTTP/1.1': '#1976d2',
'HTTP/2': '#388e3c',
'HTTP/3': '#7b1fa2',
'WebSocket': '#f57c00',
'Other': '#757575',
};
const buildDonutData = (dist: interfaces.data.IProtocolDistribution | null) => {
if (!dist) return [];
const items: Array<{ name: string; value: number; color: string }> = [];
if (dist.h1Active > 0) items.push({ name: 'HTTP/1.1', value: dist.h1Active, color: protoColors['HTTP/1.1'] });
if (dist.h2Active > 0) items.push({ name: 'HTTP/2', value: dist.h2Active, color: protoColors['HTTP/2'] });
if (dist.h3Active > 0) items.push({ name: 'HTTP/3', value: dist.h3Active, color: protoColors['HTTP/3'] });
if (dist.wsActive > 0) items.push({ name: 'WebSocket', value: dist.wsActive, color: protoColors['WebSocket'] });
if (dist.otherActive > 0) items.push({ name: 'Other', value: dist.otherActive, color: protoColors['Other'] });
return items;
};
const frontendData = buildDonutData(fp);
const backendData = buildDonutData(bp);
return html`
<div class="protocolChartGrid">
<dees-chart-donut
.label=${'Frontend Protocols'}
.data=${frontendData.length > 0 ? frontendData : [{ name: 'No Traffic', value: 1, color: '#757575' }]}
.showLegend=${true}
.showLabels=${true}
.innerRadiusPercent=${'55%'}
.valueFormatter=${(val: number) => `${val} active`}
></dees-chart-donut>
<dees-chart-donut
.label=${'Backend Protocols'}
.data=${backendData.length > 0 ? backendData : [{ name: 'No Traffic', value: 1, color: '#757575' }]}
.showLegend=${true}
.showLabels=${true}
.innerRadiusPercent=${'55%'}
.valueFormatter=${(val: number) => `${val} active`}
></dees-chart-donut>
</div>
`;
}
private renderTopIPs(): TemplateResult {
if (this.networkState.topIPs.length === 0) {
return html``;
}
// Build per-IP bandwidth lookup
const bandwidthByIP = new Map<string, { in: number; out: number }>();
if (this.networkState.throughputByIP) {
for (const entry of this.networkState.throughputByIP) {
bandwidthByIP.set(entry.ip, { in: entry.in, out: entry.out });
}
}
// Calculate total connections across all top IPs
const totalConnections = this.networkState.topIPs.reduce((sum, ipData) => sum + ipData.count, 0);
return html`
<dees-table
.data=${this.networkState.topIPs}
.displayFunction=${(ipData: { ip: string; count: number }) => {
const bw = bandwidthByIP.get(ipData.ip);
return {
'IP Address': ipData.ip,
'Connections': ipData.count,
'Bandwidth In': bw ? this.formatBitsPerSecond(bw.in) : '0 bit/s',
'Bandwidth Out': bw ? this.formatBitsPerSecond(bw.out) : '0 bit/s',
'Share': totalConnections > 0 ? ((ipData.count / totalConnections) * 100).toFixed(1) + '%' : '0%',
};
}}
heading1="Top Connected IPs"
heading2="IPs with most active connections and bandwidth"
.pagination=${false}
dataName="ip"
></dees-table>
`;
}
private renderBackendProtocols(): TemplateResult {
const backends = this.networkState.backends;
if (!backends || backends.length === 0) {
return html``;
}
return html`
<dees-table
.data=${backends}
.displayFunction=${(item: interfaces.data.IBackendInfo) => {
const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors;
const protocolClass = item.protocol.toLowerCase().replace(/[^a-z0-9]/g, '');
return {
'Backend': item.backend,
'Domain': item.domain || '-',
'Protocol': html`
<span class="protocolBadge ${protocolClass}">${item.protocol.toUpperCase()}</span>
${item.h2Suppressed ? html`<span class="suppressionBadge" title="H2 suppressed: ${item.h2ConsecutiveFailures ?? 0} failures, cooldown ${item.h2CooldownRemainingSecs ?? 0}s">H2 suppressed</span>` : ''}
${item.h3Suppressed ? html`<span class="suppressionBadge" title="H3 suppressed: ${item.h3ConsecutiveFailures ?? 0} failures, cooldown ${item.h3CooldownRemainingSecs ?? 0}s">H3 suppressed</span>` : ''}
`,
'Active': item.activeConnections,
'Total': this.formatNumber(item.totalConnections),
'Avg Connect': item.avgConnectTimeMs > 0 ? `${item.avgConnectTimeMs.toFixed(1)}ms` : '-',
'Pool Hit Rate': item.poolHitRate > 0 ? `${(item.poolHitRate * 100).toFixed(1)}%` : '-',
'Errors': totalErrors > 0
? html`<span class="statusBadge error">${totalErrors}</span>`
: html`<span class="statusBadge success">0</span>`,
'Cache Age': item.cacheAgeSecs != null ? `${Math.round(item.cacheAgeSecs)}s` : '-',
};
}}
.dataActions=${[
{
name: 'View Details',
iconName: 'lucide:info',
type: ['inRow', 'doubleClick', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
await this.showBackendDetails(actionData.item);
}
}
]}
heading1="Backend Protocols"
heading2="Auto-detected backend protocols and connection pool health"
searchable
.pagination=${false}
dataName="backend"
></dees-table>
`;
}
private async showBackendDetails(backend: interfaces.data.IBackendInfo) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: `Backend: ${backend.backend}`,
content: html`
<div style="padding: 20px;">
<dees-dataview-codebox
.heading=${'Backend Details'}
progLang="json"
.codeToDisplay=${JSON.stringify({
backend: backend.backend,
domain: backend.domain,
protocol: backend.protocol,
activeConnections: backend.activeConnections,
totalConnections: backend.totalConnections,
avgConnectTimeMs: backend.avgConnectTimeMs,
poolHitRate: backend.poolHitRate,
errors: {
connect: backend.connectErrors,
handshake: backend.handshakeErrors,
request: backend.requestErrors,
h2Failures: backend.h2Failures,
},
suppression: {
h2Suppressed: backend.h2Suppressed,
h3Suppressed: backend.h3Suppressed,
h2CooldownRemainingSecs: backend.h2CooldownRemainingSecs,
h3CooldownRemainingSecs: backend.h3CooldownRemainingSecs,
h2ConsecutiveFailures: backend.h2ConsecutiveFailures,
h3ConsecutiveFailures: backend.h3ConsecutiveFailures,
},
h3Port: backend.h3Port,
cacheAgeSecs: backend.cacheAgeSecs,
}, null, 2)}
></dees-dataview-codebox>
</div>
`,
menuOptions: [
{
name: 'Copy Backend Key',
iconName: 'lucide:Copy',
action: async () => {
await navigator.clipboard.writeText(backend.backend);
}
}
]
});
}
private async updateNetworkData() {
// Track requests/sec history for the trend sparkline (moved out of render)
const reqPerSec = this.networkState.requestsPerSecond || 0;
this.requestsPerSecHistory.push(reqPerSec);
if (this.requestsPerSecHistory.length > 20) {
this.requestsPerSecHistory.shift();
}
// Only update if connections changed significantly
const newConnectionCount = this.networkState.connections.length;
const oldConnectionCount = this.networkRequests.length;
// Check if we need to update the network requests array
const shouldUpdate = newConnectionCount !== oldConnectionCount ||
newConnectionCount === 0 ||
(newConnectionCount > 0 && this.networkRequests.length === 0);
if (shouldUpdate) {
// Convert connection data to network requests format
if (newConnectionCount > 0) {
this.networkRequests = this.networkState.connections.map((conn, index) => ({
id: conn.id,
timestamp: conn.startTime,
method: 'GET', // Default method for proxy connections
url: '/',
hostname: conn.remoteAddress,
port: conn.protocol === 'https' ? 443 : 80,
protocol: conn.protocol === 'https' || conn.protocol === 'http' ? conn.protocol : 'tcp',
statusCode: conn.state === 'connected' ? 200 : undefined,
duration: Date.now() - conn.startTime,
bytesIn: conn.bytesReceived,
bytesOut: conn.bytesSent,
remoteIp: conn.remoteAddress,
route: 'proxy',
}));
} else {
this.networkRequests = [];
}
}
// Load server-side throughput history into chart (once)
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
this.loadThroughputHistory();
}
}
private startTrafficUpdateTimer() {
this.stopTrafficUpdateTimer(); // Clear any existing timer
this.trafficUpdateTimer = setInterval(() => {
this.addTrafficDataPoint();
}, OpsViewNetworkActivity.UPDATE_INTERVAL_MS);
}
private addTrafficDataPoint() {
const now = Date.now();
// Throttle chart updates to avoid excessive re-renders
if (now - this.lastChartUpdate < this.chartUpdateThreshold) {
return;
}
const throughput = this.calculateThroughput();
// Convert to Mbps (bytes * 8 / 1,000,000)
const throughputInMbps = (throughput.in * 8) / 1000000;
const throughputOutMbps = (throughput.out * 8) / 1000000;
// Add new data points
const timestamp = new Date(now).toISOString();
const newDataPointIn = {
x: timestamp,
y: Math.round(throughputInMbps * 10) / 10
};
const newDataPointOut = {
x: timestamp,
y: Math.round(throughputOutMbps * 10) / 10
};
// In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays)
if (this.trafficDataIn.length >= OpsViewNetworkActivity.MAX_DATA_POINTS) {
this.trafficDataIn.shift();
this.trafficDataOut.shift();
}
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
this.lastChartUpdate = now;
}
private stopTrafficUpdateTimer() {
if (this.trafficUpdateTimer) {
clearInterval(this.trafficUpdateTimer);
this.trafficUpdateTimer = null;
}
}
}

View File

@@ -0,0 +1,119 @@
import * as appstate from '../../appstate.js';
import { appRouter } from '../../router.js';
import { viewHostCss } from '../shared/css.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
// Side-effect imports register the subview custom elements
import './ops-view-network-activity.js';
import './ops-view-routes.js';
import './ops-view-sourceprofiles.js';
import './ops-view-networktargets.js';
import './ops-view-targetprofiles.js';
declare global {
interface HTMLElementTagNameMap {
'ops-view-network': OpsViewNetwork;
}
}
type TNetworkTab = 'activity' | 'routes' | 'sourceprofiles' | 'networktargets' | 'targetprofiles';
@customElement('ops-view-network')
export class OpsViewNetwork extends DeesElement {
@state()
accessor selectedTab: TNetworkTab = 'activity';
private tabLabelMap: Record<TNetworkTab, string> = {
'activity': 'Network Activity',
'routes': 'Routes',
'sourceprofiles': 'Source Profiles',
'networktargets': 'Network Targets',
'targetprofiles': 'Target Profiles',
};
private labelToTab: Record<string, TNetworkTab> = {
'Network Activity': 'activity',
'Routes': 'routes',
'Source Profiles': 'sourceprofiles',
'Network Targets': 'networktargets',
'Target Profiles': 'targetprofiles',
};
private static isNetworkTab(s: string | null): s is TNetworkTab {
return s === 'activity' || s === 'routes' || s === 'sourceprofiles'
|| s === 'networktargets' || s === 'targetprofiles';
}
constructor() {
super();
// Read initial subview from state (URL-driven)
const initialState = appstate.uiStatePart.getState()!;
if (OpsViewNetwork.isNetworkTab(initialState.activeSubview)) {
this.selectedTab = initialState.activeSubview;
}
// Subscribe to future changes (back/forward navigation, direct URL entry)
const sub = appstate.uiStatePart.select((s) => s.activeSubview).subscribe((sub) => {
if (OpsViewNetwork.isNetworkTab(sub) && sub !== this.selectedTab) {
this.selectedTab = sub;
}
});
this.rxSubscriptions.push(sub);
}
async firstUpdated() {
const toggle = this.shadowRoot!.querySelector('dees-input-multitoggle') as any;
if (toggle) {
const sub = toggle.changeSubject.subscribe(() => {
const tab = this.labelToTab[toggle.selectedOption];
if (tab && tab !== this.selectedTab) {
// Push URL → router updates state → subscription updates selectedTab
appRouter.navigateToView('network', tab);
}
});
this.rxSubscriptions.push(sub);
}
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
dees-input-multitoggle {
margin-bottom: 24px;
}
`,
];
public render(): TemplateResult {
return html`
<dees-heading level="2">Network</dees-heading>
<dees-input-multitoggle
.type=${'single'}
.options=${['Network Activity', 'Routes', 'Source Profiles', 'Network Targets', 'Target Profiles']}
.selectedOption=${this.tabLabelMap[this.selectedTab]}
></dees-input-multitoggle>
${this.renderTabContent()}
`;
}
private renderTabContent(): TemplateResult {
switch (this.selectedTab) {
case 'activity': return html`<ops-view-network-activity></ops-view-network-activity>`;
case 'routes': return html`<ops-view-routes></ops-view-routes>`;
case 'sourceprofiles': return html`<ops-view-sourceprofiles></ops-view-sourceprofiles>`;
case 'networktargets': return html`<ops-view-networktargets></ops-view-networktargets>`;
case 'targetprofiles': return html`<ops-view-targetprofiles></ops-view-targetprofiles>`;
}
}
}

View File

@@ -0,0 +1,220 @@
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 { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-networktargets': OpsViewNetworkTargets;
}
}
@customElement('ops-view-networktargets')
export class OpsViewNetworkTargets extends DeesElement {
@state()
accessor profilesState: appstate.IProfilesTargetsState = appstate.profilesTargetsStatePart.getState()!;
constructor() {
super();
const sub = appstate.profilesTargetsStatePart.select().subscribe((newState) => {
this.profilesState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
}
public static styles = [
cssManager.defaultStyles,
css`
:host { display: block; }
.targetsContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
`,
];
public render(): TemplateResult {
const targets = this.profilesState.targets;
const statsTiles: IStatsTile[] = [
{
id: 'totalTargets',
title: 'Total Targets',
type: 'number',
value: targets.length,
icon: 'lucide:server',
description: 'Reusable network targets',
color: '#8b5cf6',
},
];
return html`
<dees-heading level="hr">Network Targets</dees-heading>
<div class="targetsContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading1=${'Network Targets'}
.heading2=${'Reusable host:port destinations for routes'}
.data=${targets}
.showColumnFilters=${true}
.displayFunction=${(target: interfaces.data.INetworkTarget) => ({
Name: target.name,
Host: Array.isArray(target.host) ? target.host.join(', ') : target.host,
Port: target.port,
Description: target.description || '-',
})}
.dataActions=${[
{
name: 'Create Target',
iconName: 'lucide:plus',
type: ['header' as const],
actionFunc: async () => {
await this.showCreateTargetDialog();
},
},
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header' as const],
actionFunc: async () => {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const target = actionData.item as interfaces.data.INetworkTarget;
await this.showEditTargetDialog(target);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const target = actionData.item as interfaces.data.INetworkTarget;
await this.deleteTarget(target);
},
},
]}
></dees-table>
</div>
`;
}
private async showCreateTargetDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Create Network Target',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'host'} .label=${'Host'} .required=${true}></dees-input-text>
<dees-input-text .key=${'port'} .label=${'Port'} .required=${true} .value=${'443'}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Create',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createTargetAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
host: String(data.host),
port: parseInt(String(data.port)) || 443,
});
modalArg.destroy();
},
},
],
});
}
private async showEditTargetDialog(target: interfaces.data.INetworkTarget) {
const hostStr = Array.isArray(target.host) ? target.host.join(', ') : target.host;
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: `Edit Target: ${target.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${target.name}></dees-input-text>
<dees-input-text .key=${'host'} .label=${'Host'} .value=${hostStr}></dees-input-text>
<dees-input-text .key=${'port'} .label=${'Port'} .value=${String(target.port)}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${target.description || ''}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Save',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateTargetAction, {
id: target.id,
name: String(data.name),
description: data.description ? String(data.description) : undefined,
host: String(data.host),
port: parseInt(String(data.port)) || 443,
});
modalArg.destroy();
},
},
],
});
}
private async deleteTarget(target: interfaces.data.INetworkTarget) {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteTargetAction, {
id: target.id,
force: false,
});
const currentState = appstate.profilesTargetsStatePart.getState()!;
if (currentState.error?.includes('in use')) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Target In Use',
content: html`<p>${currentState.error} Force delete?</p>`,
menuOptions: [
{
name: 'Force Delete',
action: async (modalArg: any) => {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteTargetAction, {
id: target.id,
force: true,
});
modalArg.destroy();
},
},
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
}
}

View File

@@ -0,0 +1,722 @@
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
import {
DeesElement,
css,
cssManager,
customElement,
html,
state,
type TemplateResult,
} from '@design.estate/dees-element';
// 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();
}
@customElement('ops-view-routes')
export class OpsViewRoutes extends DeesElement {
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
apiTokens: [],
isLoading: false,
error: null,
lastUpdated: 0,
};
@state() accessor profilesTargetsState: appstate.IProfilesTargetsState = {
profiles: [],
targets: [],
isLoading: false,
error: null,
lastUpdated: 0,
};
constructor() {
super();
const sub = appstate.routeManagementStatePart
.select((s) => s)
.subscribe((routeState) => {
this.routeState = routeState;
});
this.rxSubscriptions.push(sub);
const ptSub = appstate.profilesTargetsStatePart
.select((s) => s)
.subscribe((ptState) => {
this.profilesTargetsState = ptState;
});
this.rxSubscriptions.push(ptSub);
// 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);
appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
}
});
this.rxSubscriptions.push(loginSub);
}
public static styles = [
cssManager.defaultStyles,
css`
:host { display: block; }
.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 hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
const statsTiles: IStatsTile[] = [
{
id: 'totalRoutes',
title: 'Total Routes',
type: 'number',
value: mergedRoutes.length,
icon: 'lucide:route',
description: 'All configured routes',
color: '#3b82f6',
},
{
id: 'hardcoded',
title: 'Hardcoded',
type: 'number',
value: hardcodedCount,
icon: 'lucide:lock',
description: 'Routes from constructor config',
color: '#8b5cf6',
},
{
id: 'programmatic',
title: 'Programmatic',
type: 'number',
value: programmaticCount,
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 || [])];
tags.push(mr.source);
if (!mr.enabled) tags.push('disabled');
if (mr.overridden) tags.push('overridden');
return {
...mr.route,
enabled: mr.enabled,
tags,
id: mr.storedRouteId || mr.route.name || undefined,
metadata: mr.metadata,
};
});
return html`
<dees-heading level="hr">Route Management</dees-heading>
<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">&#9888;</span>
<span>${w.message}</span>
</div>
`,
)}
</div>
`
: ''}
${szRoutes.length > 0
? html`
<sz-route-list-view
.routes=${szRoutes}
.showActionsFilter=${(route: any) => route.tags?.includes('programmatic') ?? false}
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
></sz-route-list-view>
`
: html`
<div class="empty-state">
<p>No routes configured</p>
<p>Add a programmatic route or check your constructor configuration.</p>
</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');
if (merged.source === 'hardcoded') {
const menuOptions = merged.enabled
? [
{
name: 'Disable Route',
iconName: 'lucide:pause',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.setRouteOverrideAction,
{ routeName: merged.route.name!, enabled: false },
);
await modalArg.destroy();
},
},
{
name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
]
: [
{
name: 'Enable Route',
iconName: 'lucide:play',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.setRouteOverrideAction,
{ routeName: merged.route.name!, enabled: true },
);
await modalArg.destroy();
},
},
{
name: 'Remove Override',
iconName: 'lucide:undo',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.removeRouteOverrideAction,
merged.route.name!,
);
await modalArg.destroy();
},
},
{
name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
];
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
</div>
`,
menuOptions,
});
} else {
// Programmatic route
const meta = merged.metadata;
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
<p>ID: <code style="color: #888;">${merged.storedRouteId}</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.storedRouteId!, enabled: !merged.enabled },
);
await modalArg.destroy();
},
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.deleteRouteAction,
merged.storedRouteId!,
);
await modalArg.destroy();
},
},
{
name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
],
});
}
}
private async handleRouteEdit(e: CustomEvent) {
const clickedRoute = e.detail;
if (!clickedRoute) return;
const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name,
);
if (!merged || !merged.storedRouteId) return;
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,
);
if (!merged || !merged.storedRouteId) return;
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,
merged.storedRouteId!,
);
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);
const currentDomains: string[] = route.match.domains
? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
: [];
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) : '';
// 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({
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>
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .value=${currentPorts} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.sourceProfileRef || '')) || null}></dees-input-dropdown>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${currentTargetHost}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'} .value=${currentTargetPort}></dees-input-text>
<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;">
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'} .value=${currentCustomKey}></dees-input-text>
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'} .value=${currentCustomCert}></dees-input-text>
</div>
</div>
</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));
const domains: string[] = Array.isArray(formData.domains)
? formData.domains.filter(Boolean)
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const updatedRoute: any = {
name: formData.name,
match: {
ports,
...(domains.length > 0 ? { domains } : {}),
},
action: {
type: 'forward',
targets: [
{
host: formData.targetHost || 'localhost',
port: parseInt(formData.targetPort, 10) || 443,
},
],
},
...(priority != null && !isNaN(priority) ? { priority } : {}),
};
// 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
}
const metadata: any = {};
const profileRefValue = formData.sourceProfileRef as any;
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
if (profileKey) {
metadata.sourceProfileRef = profileKey;
}
const targetRefValue = formData.networkTargetRef as any;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
if (targetKey) {
metadata.networkTargetRef = targetKey;
}
await appstate.routeManagementStatePart.dispatchAction(
appstate.updateRouteAction,
{
id: merged.storedRouteId!,
route: updatedRoute,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
},
);
await modalArg.destroy();
},
},
],
});
// 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);
}
}
private async showCreateRouteDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
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})`,
})),
];
const createModal = await DeesModal.createAndShow({
heading: 'Add Programmatic Route',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'}></dees-input-text>
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${'localhost'}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'}></dees-input-text>
<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;">
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'}></dees-input-text>
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'}></dees-input-text>
</div>
</div>
</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));
const domains: string[] = Array.isArray(formData.domains)
? formData.domains.filter(Boolean)
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const route: any = {
name: formData.name,
match: {
ports,
...(domains.length > 0 ? { domains } : {}),
},
action: {
type: 'forward',
targets: [
{
host: formData.targetHost || 'localhost',
port: parseInt(formData.targetPort, 10) || 443,
},
],
},
...(priority != null && !isNaN(priority) ? { priority } : {}),
};
// 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;
}
// Build metadata if profile/target selected
const metadata: any = {};
const profileRefValue = formData.sourceProfileRef as any;
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
if (profileKey) {
metadata.sourceProfileRef = profileKey;
}
const targetRefValue = formData.networkTargetRef as any;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
if (targetKey) {
metadata.networkTargetRef = targetKey;
}
await appstate.routeManagementStatePart.dispatchAction(
appstate.createRouteAction,
{
route,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
},
);
await modalArg.destroy();
},
},
],
});
// 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);
}
}
private refreshData() {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
}

View File

@@ -0,0 +1,242 @@
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 { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-sourceprofiles': OpsViewSourceProfiles;
}
}
@customElement('ops-view-sourceprofiles')
export class OpsViewSourceProfiles extends DeesElement {
@state()
accessor profilesState: appstate.IProfilesTargetsState = appstate.profilesTargetsStatePart.getState()!;
constructor() {
super();
const sub = appstate.profilesTargetsStatePart.select().subscribe((newState) => {
this.profilesState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
}
public static styles = [
cssManager.defaultStyles,
css`
:host { display: block; }
.profilesContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
`,
];
public render(): TemplateResult {
const profiles = this.profilesState.profiles;
const statsTiles: IStatsTile[] = [
{
id: 'totalProfiles',
title: 'Total Profiles',
type: 'number',
value: profiles.length,
icon: 'lucide:shieldCheck',
description: 'Reusable source profiles',
color: '#3b82f6',
},
];
return html`
<dees-heading level="hr">Source Profiles</dees-heading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading1=${'Source Profiles'}
.heading2=${'Reusable source configurations for routes'}
.data=${profiles}
.showColumnFilters=${true}
.displayFunction=${(profile: interfaces.data.ISourceProfile) => ({
Name: profile.name,
Description: profile.description || '-',
'IP Allow List': (profile.security?.ipAllowList || []).join(', ') || '-',
'IP Block List': (profile.security?.ipBlockList || []).join(', ') || '-',
'Max Connections': profile.security?.maxConnections ?? '-',
'Rate Limit': profile.security?.rateLimit?.enabled ? `${profile.security.rateLimit.maxRequests}/${profile.security.rateLimit.window}s` : '-',
Extends: (profile.extendsProfiles || []).length > 0
? profile.extendsProfiles!.map(id => {
const p = profiles.find(pp => pp.id === id);
return p ? p.name : id.slice(0, 8);
}).join(', ')
: '-',
})}
.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.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ISourceProfile;
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.ISourceProfile;
await this.deleteProfile(profile);
},
},
]}
></dees-table>
</div>
`;
}
private async showCreateProfileDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Create Source 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>
<dees-input-list .key=${'ipAllowList'} .label=${'IP Allow List'} .placeholder=${'Add IP or CIDR...'}></dees-input-list>
<dees-input-list .key=${'ipBlockList'} .label=${'IP Block List'} .placeholder=${'Add IP or CIDR...'}></dees-input-list>
<dees-input-text .key=${'maxConnections'} .label=${'Max Connections'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Create',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
security: {
...(ipAllowList.length > 0 ? { ipAllowList } : {}),
...(ipBlockList.length > 0 ? { ipBlockList } : {}),
...(maxConnections ? { maxConnections } : {}),
},
});
modalArg.destroy();
},
},
],
});
}
private async showEditProfileDialog(profile: interfaces.data.ISourceProfile) {
const { DeesModal } = await import('@design.estate/dees-catalog');
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>
<dees-input-list .key=${'ipAllowList'} .label=${'IP Allow List'} .placeholder=${'Add IP or CIDR...'} .value=${profile.security?.ipAllowList || []}></dees-input-list>
<dees-input-list .key=${'ipBlockList'} .label=${'IP Block List'} .placeholder=${'Add IP or CIDR...'} .value=${profile.security?.ipBlockList || []}></dees-input-list>
<dees-input-text .key=${'maxConnections'} .label=${'Max Connections'} .value=${String(profile.security?.maxConnections || '')}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Save',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, {
id: profile.id,
name: String(data.name),
description: data.description ? String(data.description) : undefined,
security: {
ipAllowList,
ipBlockList,
...(maxConnections ? { maxConnections } : {}),
},
});
modalArg.destroy();
},
},
],
});
}
private async deleteProfile(profile: interfaces.data.ISourceProfile) {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteProfileAction, {
id: profile.id,
force: false,
});
const currentState = appstate.profilesTargetsStatePart.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',
action: async (modalArg: any) => {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteProfileAction, {
id: profile.id,
force: true,
});
modalArg.destroy();
},
},
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
}
}

View File

@@ -0,0 +1,391 @@
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 { 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,
css`
:host { display: block; }
.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`
<dees-heading level="hr">Target Profiles</dees-heading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading1=${'Target Profiles'}
.heading2=${'Define what resources VPN clients can access'}
.data=${profiles}
.showColumnFilters=${true}
.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
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
: '-',
'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
: '-',
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>
`;
}
private getRouteCandidates() {
const routeState = appstate.routeManagementStatePart.getState();
const routes = routeState?.mergedRoutes || [];
return routes
.filter((mr) => mr.route.name)
.map((mr) => ({ viewKey: mr.route.name! }));
}
private async ensureRoutesLoaded() {
const routeState = appstate.routeManagementStatePart.getState();
if (!routeState?.mergedRoutes?.length) {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
}
private async showCreateProfileDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
await this.ensureRoutesLoaded();
const routeCandidates = this.getRouteCandidates();
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>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
</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;
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 {
ip: s.substring(0, lastColon),
port: parseInt(s.substring(lastColon + 1), 10),
};
})
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
domains: domains.length > 0 ? domains : undefined,
targets: targets.length > 0 ? targets : undefined,
routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
});
modalArg.destroy();
},
},
],
});
}
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
const currentDomains = profile.domains || [];
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
const currentRouteRefs = profile.routeRefs || [];
const { DeesModal } = await import('@design.estate/dees-catalog');
await this.ensureRoutesLoaded();
const routeCandidates = this.getRouteCandidates();
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>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
</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();
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 {
ip: s.substring(0, lastColon),
port: parseInt(s.substring(lastColon + 1), 10),
};
})
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
id: profile.id,
name: String(data.name),
description: data.description ? String(data.description) : undefined,
domains,
targets,
routeRefs,
});
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
? profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)
: '-'}
</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
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
: '-'}
</div>
</div>
<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() },
],
});
}
}
}