Files
dcrouter/ts_web/elements/network/ops-view-network-activity.ts
T

967 lines
34 KiB
TypeScript
Raw Normal View History

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 { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-network-activity': OpsViewNetworkActivity;
}
}
@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 trafficDataIn: Array<{ x: string | number; y: number }> = [];
2025-06-23 00:19:47 +00:00
@state()
accessor trafficDataOut: Array<{ x: string | number; y: number }> = [];
2025-07-02 11:33:50 +00:00
// 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
2025-06-22 23:40:02 +00:00
private trafficUpdateTimer: any = null;
2025-06-23 00:19:47 +00:00
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();
2025-06-22 23:40:02 +00:00
this.initializeTrafficData();
this.updateNetworkData();
2025-06-22 23:40:02 +00:00
this.startTrafficUpdateTimer();
}
2025-07-02 11:33:50 +00:00
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);
2025-07-02 11:33:50 +00:00
// When network view becomes visible, ensure we fetch network data
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
}
2025-06-22 23:40:02 +00:00
async disconnectedCallback() {
await super.disconnectedCallback();
this.stopTrafficUpdateTimer();
if (this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
}
private subscribeToStateParts() {
2025-07-02 11:33:50 +00:00
// Subscribe and track unsubscribe functions
const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => {
this.statsState = state;
});
2025-07-02 11:33:50 +00:00
this.rxSubscriptions.push(statsUnsubscribe);
const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
this.networkState = state;
this.updateNetworkData();
});
2025-07-02 11:33:50 +00:00
this.rxSubscriptions.push(networkUnsubscribe);
}
2025-06-22 23:40:02 +00:00
private initializeTrafficData() {
const now = Date.now();
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetworkActivity;
2025-06-23 00:19:47 +00:00
// 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);
2025-06-22 23:40:02 +00:00
return {
x: new Date(time).toISOString(),
y: 0,
};
});
2025-06-23 00:19:47 +00:00
this.trafficDataIn = [...emptyData];
this.trafficDataOut = emptyData.map(point => ({ ...point }));
2025-06-22 23:40:02 +00:00
}
/**
* 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,
viewHostCss,
css`
.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')};
}
.intelligenceBadge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#eef2ff', '#1e1b4b')};
color: ${cssManager.bdTheme('#4338ca', '#a5b4fc')};
}
.protocolChartGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
`,
];
public render() {
return html`
<dees-heading level="3">Network Activity</dees-heading>
<div class="networkContainer">
<!-- Stats Grid -->
${this.renderNetworkStats()}
<!-- Traffic Chart -->
<dees-chart-area
.label=${'Network Traffic'}
.series=${[
{
2025-06-23 00:19:47 +00:00
name: 'Inbound',
data: this.trafficDataIn,
color: '#22c55e',
2025-06-23 00:19:47 +00:00
},
{
name: 'Outbound',
data: this.trafficDataOut,
color: '#8b5cf6',
}
]}
.realtimeMode=${true}
.rollingWindow=${OpsViewNetworkActivity.CHART_WINDOW_MS}
2025-06-23 00:19:47 +00:00
.yAxisFormatter=${(val: number) => `${val} Mbit/s`}
></dees-chart-area>
<!-- Protocol Distribution Charts -->
${this.renderProtocolCharts()}
<!-- Top IPs by Connection Count -->
${this.renderTopIPs()}
<!-- Top IPs by Bandwidth -->
${this.renderTopIPsByBandwidth()}
<!-- Domain Activity -->
${this.renderDomainActivity()}
<!-- Backend Protocols Section -->
${this.renderBackendProtocols()}
</div>
`;
}
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]}`;
}
2025-06-23 00:19:47 +00:00
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;
2025-06-23 00:19:47 +00:00
while (size >= 1000 && unitIndex < units.length - 1) {
size /= 1000; // Use 1000 for bits (not 1024)
unitIndex++;
}
2025-06-23 00:19:47 +00:00
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
private formatOptional(value: unknown): string {
if (value === null || value === undefined || value === '') return '-';
return String(value);
}
private formatDateTime(timestamp?: number | null): string {
return timestamp ? new Date(timestamp).toLocaleString() : '-';
}
private getIpIntelligence(ip: string): interfaces.data.IIpIntelligenceRecord | undefined {
return this.networkState.ipIntelligence?.find((record) => record.ipAddress === ip);
}
private getIpOrganization(record?: interfaces.data.IIpIntelligenceRecord): string {
return record?.asnOrg || record?.registrantOrg || '';
}
private getIpIntelligenceColumns(ip: string): Record<string, unknown> {
const record = this.getIpIntelligence(ip);
const organization = this.getIpOrganization(record);
return {
'Intelligence': record
? html`<span class="intelligenceBadge">${this.formatOptional(organization || record.countryCode || 'Known')}</span>`
: html`<span class="statusBadge warning">Enriching...</span>`,
'ASN': record?.asn ? `AS${record.asn}` : '-',
'Organization': this.formatOptional(organization),
'Country': this.formatOptional(record?.countryCode || record?.country),
'Network Range': this.formatOptional(record?.networkRange),
'Last Seen': this.formatDateTime(record?.lastSeenAt),
};
}
private getIpDataActions() {
return [
{
name: 'Refresh Intelligence',
iconName: 'lucide:refresh-cw',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const ip = actionData.item.ip;
await appstate.securityPolicyStatePart.dispatchAction(appstate.refreshIpIntelligenceAction, ip);
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
},
},
{
name: 'Block IP',
iconName: 'lucide:shield-ban',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
await this.createBlockRuleDialog('ip', actionData.item.ip, 'Blocked from Network Activity');
},
},
{
name: 'Block Network Range',
iconName: 'lucide:network',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)?.networkRange),
actionFunc: async (actionData: any) => {
const record = this.getIpIntelligence(actionData.item.ip);
await this.createBlockRuleDialog('cidr', record!.networkRange!, 'Blocked network range from Network Activity');
},
},
{
name: 'Block ASN',
iconName: 'lucide:radio-tower',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)?.asn),
actionFunc: async (actionData: any) => {
const record = this.getIpIntelligence(actionData.item.ip);
await this.createBlockRuleDialog('asn', String(record!.asn), 'Blocked ASN from Network Activity');
},
},
{
name: 'Block Organization',
iconName: 'lucide:building-2',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpOrganization(this.getIpIntelligence(actionData.item.ip))),
actionFunc: async (actionData: any) => {
const record = this.getIpIntelligence(actionData.item.ip);
await this.createBlockRuleDialog('organization', this.getIpOrganization(record), 'Blocked organization from Network Activity');
},
},
{
name: 'View Intelligence',
iconName: 'lucide:info',
type: ['doubleClick', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)),
actionFunc: async (actionData: any) => {
await this.showIpIntelligenceDetails(actionData.item.ip);
},
},
];
}
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)
2025-06-23 00:19:47 +00:00
const trendData = [...this.requestsPerSecHistory];
2025-06-22 23:40:02 +00:00
while (trendData.length < 20) {
2025-06-23 00:19:47 +00:00
trendData.unshift(0);
2025-06-22 23:40:02 +00:00
}
const tiles: IStatsTile[] = [
{
id: 'connections',
title: 'Active Connections',
value: activeConnections,
type: 'number',
icon: 'lucide:Plug',
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
description: `Total: ${this.formatNumber(this.statsState.serverStats?.totalConnections || 0)} connections`,
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',
2025-06-23 00:19:47 +00:00
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',
2025-06-23 00:19:47 +00:00
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 });
}
}
2025-06-23 13:24:43 +00:00
// 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}
.rowKey=${'ip'}
.highlightUpdates=${'flash'}
.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%',
...this.getIpIntelligenceColumns(ipData.ip),
};
}}
.dataActions=${this.getIpDataActions()}
heading1="Top Connected IPs"
heading2="IPs with most active connections, bandwidth, and intelligence"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="ip"
></dees-table>
`;
}
private renderTopIPsByBandwidth(): TemplateResult {
if (!this.networkState.topIPsByBandwidth || this.networkState.topIPsByBandwidth.length === 0) {
return html``;
}
return html`
<dees-table
.data=${this.networkState.topIPsByBandwidth}
.rowKey=${'ip'}
.highlightUpdates=${'flash'}
.displayFunction=${(ipData: { ip: string; count: number; bwIn: number; bwOut: number }) => {
return {
'IP Address': ipData.ip,
'Bandwidth In': this.formatBitsPerSecond(ipData.bwIn),
'Bandwidth Out': this.formatBitsPerSecond(ipData.bwOut),
'Total Bandwidth': this.formatBitsPerSecond(ipData.bwIn + ipData.bwOut),
'Connections': ipData.count,
...this.getIpIntelligenceColumns(ipData.ip),
};
}}
.dataActions=${this.getIpDataActions()}
heading1="Top IPs by Bandwidth"
heading2="IPs with highest throughput and intelligence"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="ip"
></dees-table>
`;
}
private renderDomainActivity(): TemplateResult {
if (!this.networkState.domainActivity || this.networkState.domainActivity.length === 0) {
return html``;
}
return html`
<dees-table
.data=${this.networkState.domainActivity}
.rowKey=${'domain'}
.highlightUpdates=${'flash'}
.displayFunction=${(item: interfaces.data.IDomainActivity) => {
const totalBytesPerMin = (item.bytesInPerSecond + item.bytesOutPerSecond) * 60;
return {
'Domain': item.domain,
'Throughput In': this.formatBitsPerSecond(item.bytesInPerSecond),
'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond),
'Transferred / min': this.formatBytes(totalBytesPerMin),
'Connections': item.activeConnections,
'Req/s': item.requestsPerSecond != null ? item.requestsPerSecond.toFixed(1) : '-',
'Req/min': item.requestsLastMinute != null ? item.requestsLastMinute.toFixed(0) : '-',
'Requests': item.requestCount?.toLocaleString() ?? '0',
'Routes': item.routeCount,
};
}}
heading1="Domain Activity"
heading2="Per-domain network activity from request-level metrics"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="domain"
></dees-table>
`;
}
private renderBackendProtocols(): TemplateResult {
const backends = this.networkState.backends;
if (!backends || backends.length === 0) {
return html``;
}
return html`
<dees-table
.data=${backends}
.rowKey=${'id'}
.highlightUpdates=${'flash'}
.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
.showColumnFilters=${true}
.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 getDropdownKey(value: any): string {
return typeof value === 'string' ? value : value?.key || '';
}
private async createBlockRuleDialog(
type: interfaces.data.TSecurityBlockRuleType,
value: string,
reason: string,
): Promise<void> {
const { DeesModal } = await import('@design.estate/dees-catalog');
const typeOptions = [
{ key: 'ip', option: 'IP address' },
{ key: 'cidr', option: 'CIDR / network range' },
{ key: 'asn', option: 'ASN' },
{ key: 'organization', option: 'Organization' },
];
const matchModeOptions = [
{ key: 'contains', option: 'Organization contains value' },
{ key: 'exact', option: 'Organization exactly matches value' },
];
await DeesModal.createAndShow({
heading: 'Create Security Block Rule',
content: html`
<dees-form>
<dees-input-dropdown
.key=${'type'}
.label=${'Rule Type'}
.options=${typeOptions}
.selectedOption=${typeOptions.find((option) => option.key === type)}
></dees-input-dropdown>
<dees-input-text .key=${'value'} .label=${'Value'} .value=${value} .required=${true}></dees-input-text>
<dees-input-dropdown
.key=${'matchMode'}
.label=${'Organization Match Mode'}
.description=${'Only used for organization rules'}
.options=${matchModeOptions}
.selectedOption=${matchModeOptions[0]}
></dees-input-dropdown>
<dees-input-text .key=${'reason'} .label=${'Reason'} .value=${reason}></dees-input-text>
<dees-input-checkbox .key=${'enabled'} .label=${'Enable immediately'} .value=${true}></dees-input-checkbox>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Create',
iconName: 'lucide:shield-ban',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const selectedType = this.getDropdownKey(data.type) as interfaces.data.TSecurityBlockRuleType;
const selectedValue = String(data.value || '').trim();
if (!selectedType || !selectedValue) return;
const matchMode = selectedType === 'organization'
? this.getDropdownKey(data.matchMode) as interfaces.data.TSecurityBlockRuleMatchMode
: undefined;
await appstate.securityPolicyStatePart.dispatchAction(appstate.createSecurityBlockRuleAction, {
type: selectedType,
value: selectedValue,
matchMode,
reason: String(data.reason || '').trim() || undefined,
enabled: data.enabled !== false,
});
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
await modalArg.destroy();
},
},
],
});
}
private async showIpIntelligenceDetails(ip: string): Promise<void> {
const record = this.getIpIntelligence(ip);
if (!record) return;
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: `IP Intelligence: ${ip}`,
content: html`
<div style="padding: 20px;">
<dees-dataview-codebox
.heading=${'Intelligence Record'}
progLang="json"
.codeToDisplay=${JSON.stringify(record, null, 2)}
></dees-dataview-codebox>
</div>
`,
menuOptions: [
{
name: 'Copy Abuse Contact',
iconName: 'lucide:copy',
action: async () => {
if (record.abuseContact) await navigator.clipboard.writeText(record.abuseContact);
},
},
{
name: 'Block IP',
iconName: 'lucide:shield-ban',
action: async () => {
await this.createBlockRuleDialog('ip', record.ipAddress, 'Blocked from IP intelligence details');
},
},
],
});
}
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();
}
// Load server-side throughput history into chart (once)
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
this.loadThroughputHistory();
}
2025-07-02 11:33:50 +00:00
}
2025-07-02 11:33:50 +00:00
private startTrafficUpdateTimer() {
this.stopTrafficUpdateTimer(); // Clear any existing timer
this.trafficUpdateTimer = setInterval(() => {
this.addTrafficDataPoint();
}, OpsViewNetworkActivity.UPDATE_INTERVAL_MS);
2025-07-02 11:33:50 +00:00
}
2025-07-02 11:33:50 +00:00
private addTrafficDataPoint() {
const now = Date.now();
2025-07-02 11:33:50 +00:00
// Throttle chart updates to avoid excessive re-renders
if (now - this.lastChartUpdate < this.chartUpdateThreshold) {
2025-06-22 23:40:02 +00:00
return;
}
2025-06-23 00:19:47 +00:00
const throughput = this.calculateThroughput();
if (this.networkState.lastUpdated && now - this.networkState.lastUpdated > 3000) {
return;
}
2025-06-23 00:19:47 +00:00
// Convert to Mbps (bytes * 8 / 1,000,000)
const throughputInMbps = (throughput.in * 8) / 1000000;
const throughputOutMbps = (throughput.out * 8) / 1000000;
2025-07-02 11:33:50 +00:00
// Add new data points
const timestamp = new Date(now).toISOString();
2025-07-02 11:33:50 +00:00
const newDataPointIn = {
x: timestamp,
y: Math.round(throughputInMbps * 10) / 10
};
2025-07-02 11:33:50 +00:00
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();
2025-06-22 23:40:02 +00:00
}
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
2025-07-02 11:33:50 +00:00
this.lastChartUpdate = now;
2025-06-22 23:40:02 +00:00
}
2025-06-22 23:40:02 +00:00
private stopTrafficUpdateTimer() {
if (this.trafficUpdateTimer) {
clearInterval(this.trafficUpdateTimer);
this.trafficUpdateTimer = null;
}
}
}