967 lines
34 KiB
TypeScript
967 lines
34 KiB
TypeScript
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 }> = [];
|
|
|
|
@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.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,
|
|
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=${[
|
|
{
|
|
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 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]}`;
|
|
}
|
|
|
|
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 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)
|
|
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.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',
|
|
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}
|
|
.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();
|
|
}
|
|
}
|
|
|
|
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();
|
|
if (this.networkState.lastUpdated && now - this.networkState.lastUpdated > 3000) {
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|