feat(network): add bandwidth-ranked IP and domain activity metrics to network monitoring

This commit is contained in:
2026-04-13 11:04:15 +00:00
parent 07a3365496
commit 035173702d
10 changed files with 275 additions and 227 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # Changelog
## 2026-04-13 - 13.14.0 - feat(network)
add bandwidth-ranked IP and domain activity metrics to network monitoring
- Expose top IPs by bandwidth and aggregated domain activity from route metrics.
- Replace estimated per-connection values with real per-IP throughput data in ops handlers and stats responses.
- Update the network UI to show bandwidth-ranked IPs and domain activity while removing the recent request table.
## 2026-04-13 - 13.13.0 - feat(dns) ## 2026-04-13 - 13.13.0 - feat(dns)
add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.13.0', version: '13.14.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -553,12 +553,14 @@ export class MetricsManager {
connectionsByIP: new Map<string, number>(), connectionsByIP: new Map<string, number>(),
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [] as Array<{ ip: string; count: number }>, topIPs: [] as Array<{ ip: string; count: number }>,
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
totalDataTransferred: { bytesIn: 0, bytesOut: 0 }, totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>, throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
throughputByIP: new Map<string, { in: number; out: number }>(), throughputByIP: new Map<string, { in: number; out: number }>(),
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [] as Array<any>, backends: [] as Array<any>,
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number }>,
}; };
} }
@@ -572,7 +574,7 @@ export class MetricsManager {
bytesOutPerSecond: instantThroughput.out bytesOutPerSecond: instantThroughput.out
}; };
// Get top IPs // Get top IPs by connection count
const topIPs = proxyMetrics.connections.topIPs(10); const topIPs = proxyMetrics.connections.topIPs(10);
// Get total data transferred // Get total data transferred
@@ -699,10 +701,83 @@ export class MetricsManager {
} }
} }
// Build top 10 IPs by bandwidth (sorted by total throughput desc)
const allIPData = new Map<string, { count: number; bwIn: number; bwOut: number }>();
for (const [ip, count] of connectionsByIP) {
allIPData.set(ip, { count, bwIn: 0, bwOut: 0 });
}
for (const [ip, tp] of throughputByIP) {
const existing = allIPData.get(ip);
if (existing) {
existing.bwIn = tp.in;
existing.bwOut = tp.out;
} else {
allIPData.set(ip, { count: 0, bwIn: tp.in, bwOut: tp.out });
}
}
const topIPsByBandwidth = Array.from(allIPData.entries())
.sort((a, b) => (b[1].bwIn + b[1].bwOut) - (a[1].bwIn + a[1].bwOut))
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
// Build domain activity from per-route metrics
const connectionsByRoute = proxyMetrics.connections.byRoute();
const throughputByRoute = proxyMetrics.throughput.byRoute();
// Map route name → primary domain using dcrouter's route configs
const routeToDomain = new Map<string, string>();
if (this.dcRouter.smartProxy) {
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
if (!route.name || !route.match.domains) continue;
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
if (domains.length > 0) {
routeToDomain.set(route.name, domains[0]);
}
}
}
// Aggregate metrics by domain
const domainAgg = new Map<string, {
activeConnections: number;
bytesInPerSec: number;
bytesOutPerSec: number;
routeCount: number;
}>();
for (const [routeName, activeConns] of connectionsByRoute) {
const domain = routeToDomain.get(routeName) || routeName;
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
const existing = domainAgg.get(domain);
if (existing) {
existing.activeConnections += activeConns;
existing.bytesInPerSec += tp.in;
existing.bytesOutPerSec += tp.out;
existing.routeCount++;
} else {
domainAgg.set(domain, {
activeConnections: activeConns,
bytesInPerSec: tp.in,
bytesOutPerSec: tp.out,
routeCount: 1,
});
}
}
const domainActivity = Array.from(domainAgg.entries())
.map(([domain, data]) => ({
domain,
bytesInPerSecond: data.bytesInPerSec,
bytesOutPerSecond: data.bytesOutPerSec,
activeConnections: data.activeConnections,
routeCount: data.routeCount,
}))
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
return { return {
connectionsByIP, connectionsByIP,
throughputRate, throughputRate,
topIPs, topIPs,
topIPsByBandwidth,
totalDataTransferred, totalDataTransferred,
throughputHistory, throughputHistory,
throughputByIP, throughputByIP,
@@ -711,6 +786,7 @@ export class MetricsManager {
backends, backends,
frontendProtocols, frontendProtocols,
backendProtocols, backendProtocols,
domainActivity,
}; };
}, 1000); // 1s cache — matches typical dashboard poll interval }, 1000); // 1s cache — matches typical dashboard poll interval
} }

View File

@@ -51,8 +51,8 @@ export class SecurityHandler {
startTime: conn.startTime, startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type as any, protocol: conn.type === 'http' ? 'https' : conn.type as any,
state: conn.status as any, state: conn.status as any,
bytesReceived: Math.floor(conn.bytesTransferred / 2), bytesReceived: (conn as any)._throughputIn || 0,
bytesSent: Math.floor(conn.bytesTransferred / 2), bytesSent: (conn as any)._throughputOut || 0,
})); }));
const summary = { const summary = {
@@ -96,9 +96,11 @@ export class SecurityHandler {
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })), connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
throughputRate: networkStats.throughputRate, throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs, topIPs: networkStats.topIPs,
topIPsByBandwidth: networkStats.topIPsByBandwidth,
totalDataTransferred: networkStats.totalDataTransferred, totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [], throughputHistory: networkStats.throughputHistory || [],
throughputByIP, throughputByIP,
domainActivity: networkStats.domainActivity || [],
requestsPerSecond: networkStats.requestsPerSecond || 0, requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0, requestsTotal: networkStats.requestsTotal || 0,
backends: networkStats.backends || [], backends: networkStats.backends || [],
@@ -110,9 +112,11 @@ export class SecurityHandler {
connectionsByIP: [], connectionsByIP: [],
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [], topIPs: [],
topIPsByBandwidth: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 }, totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [], throughputHistory: [],
throughputByIP: [], throughputByIP: [],
domainActivity: [],
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [], backends: [],
@@ -251,31 +255,31 @@ export class SecurityHandler {
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo(); const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats(); const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// Use IP-based connection data from the new metrics API // One aggregate row per IP with real throughput data
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) { if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
let connIndex = 0; let connIndex = 0;
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server'; const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
for (const [ip, count] of networkStats.connectionsByIP) { for (const [ip, count] of networkStats.connectionsByIP) {
// Create a connection entry for each active IP connection const tp = networkStats.throughputByIP?.get(ip);
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance connections.push({
connections.push({ id: `ip-${connIndex++}`,
id: `conn-${connIndex++}`, type: 'http',
type: 'http', source: {
source: { ip: ip,
ip: ip, port: 0,
port: Math.floor(Math.random() * 50000) + 10000, // High port range },
}, destination: {
destination: { ip: publicIp,
ip: publicIp, port: 443,
port: 443, service: 'proxy',
service: 'proxy', },
}, startTime: 0,
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour bytesTransferred: count, // Store connection count here
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size), status: 'active',
status: 'active', // Attach real throughput for the handler mapping
}); ...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
} } as any);
} }
} else if (connectionInfo.length > 0) { } else if (connectionInfo.length > 0) {
// Fallback to route-based connection info if no IP data available // Fallback to route-based connection info if no IP data available

View File

@@ -291,6 +291,20 @@ export class StatsHandler {
} }
} }
// Build connectionDetails from real per-IP data
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
for (const [ip, count] of stats.connectionsByIP) {
const tp = stats.throughputByIP?.get(ip);
connectionDetails.push({
remoteAddress: ip,
protocol: 'https',
state: 'connected',
startTime: 0,
bytesIn: tp?.in || 0,
bytesOut: tp?.out || 0,
});
}
metrics.network = { metrics.network = {
totalBandwidth: { totalBandwidth: {
in: stats.throughputRate.bytesInPerSecond, in: stats.throughputRate.bytesInPerSecond,
@@ -301,12 +315,18 @@ export class StatsHandler {
out: stats.totalDataTransferred.bytesOut, out: stats.totalDataTransferred.bytesOut,
}, },
activeConnections: serverStats.activeConnections, activeConnections: serverStats.activeConnections,
connectionDetails: [], connectionDetails,
topEndpoints: stats.topIPs.map(ip => ({ topEndpoints: stats.topIPs.map(ip => ({
endpoint: ip.ip, endpoint: ip.ip,
requests: ip.count, connections: ip.count,
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 }, bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
})), })),
topEndpointsByBandwidth: stats.topIPsByBandwidth.map(ip => ({
endpoint: ip.ip,
connections: ip.count,
bandwidth: { in: ip.bwIn, out: ip.bwOut },
})),
domainActivity: stats.domainActivity || [],
throughputHistory: stats.throughputHistory || [], throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0, requestsPerSecond: stats.requestsPerSecond || 0,
requestsTotal: stats.requestsTotal || 0, requestsTotal: stats.requestsTotal || 0,

View File

@@ -143,6 +143,14 @@ export interface IHealthStatus {
version?: string; version?: string;
} }
export interface IDomainActivity {
domain: string;
bytesInPerSecond: number;
bytesOutPerSecond: number;
activeConnections: number;
routeCount: number;
}
export interface INetworkMetrics { export interface INetworkMetrics {
totalBandwidth: { totalBandwidth: {
in: number; in: number;
@@ -156,12 +164,21 @@ export interface INetworkMetrics {
connectionDetails: IConnectionDetails[]; connectionDetails: IConnectionDetails[];
topEndpoints: Array<{ topEndpoints: Array<{
endpoint: string; endpoint: string;
requests: number; connections: number;
bandwidth: { bandwidth: {
in: number; in: number;
out: number; out: number;
}; };
}>; }>;
topEndpointsByBandwidth: Array<{
endpoint: string;
connections: number;
bandwidth: {
in: number;
out: number;
};
}>;
domainActivity: IDomainActivity[];
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>; throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond?: number; requestsPerSecond?: number;
requestsTotal?: number; requestsTotal?: number;

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.13.0', version: '13.14.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -52,7 +52,9 @@ export interface INetworkState {
throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number }; throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number };
totalBytes: { in: number; out: number }; totalBytes: { in: number; out: number };
topIPs: Array<{ ip: string; count: number }>; topIPs: Array<{ ip: string; count: number }>;
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
throughputByIP: Array<{ ip: string; in: number; out: number }>; throughputByIP: Array<{ ip: string; in: number; out: number }>;
domainActivity: interfaces.data.IDomainActivity[];
throughputHistory: Array<{ timestamp: number; in: number; out: number }>; throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond: number; requestsPerSecond: number;
requestsTotal: number; requestsTotal: number;
@@ -160,7 +162,9 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
totalBytes: { in: 0, out: 0 }, totalBytes: { in: 0, out: 0 },
topIPs: [], topIPs: [],
topIPsByBandwidth: [],
throughputByIP: [], throughputByIP: [],
domainActivity: [],
throughputHistory: [], throughputHistory: [],
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
@@ -552,7 +556,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
? { in: networkStatsResponse.totalDataTransferred.bytesIn, out: networkStatsResponse.totalDataTransferred.bytesOut } ? { in: networkStatsResponse.totalDataTransferred.bytesIn, out: networkStatsResponse.totalDataTransferred.bytesOut }
: { in: 0, out: 0 }, : { in: 0, out: 0 },
topIPs: networkStatsResponse.topIPs || [], topIPs: networkStatsResponse.topIPs || [],
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
throughputByIP: networkStatsResponse.throughputByIP || [], throughputByIP: networkStatsResponse.throughputByIP || [],
domainActivity: networkStatsResponse.domainActivity || [],
throughputHistory: networkStatsResponse.throughputHistory || [], throughputHistory: networkStatsResponse.throughputHistory || [],
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0, requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
requestsTotal: networkStatsResponse.requestsTotal || 0, requestsTotal: networkStatsResponse.requestsTotal || 0,
@@ -2649,67 +2655,52 @@ async function dispatchCombinedRefreshActionInner() {
if (combinedResponse.metrics.network && currentView === 'network') { if (combinedResponse.metrics.network && currentView === 'network') {
const network = combinedResponse.metrics.network; const network = combinedResponse.metrics.network;
const connectionsByIP: { [ip: string]: number } = {}; const connectionsByIP: { [ip: string]: number } = {};
// Convert connection details to IP counts // Build connectionsByIP from connectionDetails (now populated with real per-IP data)
network.connectionDetails.forEach(conn => { network.connectionDetails.forEach(conn => {
connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1; connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1;
}); });
// Fetch detailed connections for the network view // Build connections from connectionDetails (real per-IP aggregates)
try { const connections: interfaces.data.IConnectionInfo[] = network.connectionDetails.map((conn, i) => ({
const connectionsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< id: `ip-${conn.remoteAddress}`,
interfaces.requests.IReq_GetActiveConnections remoteAddress: conn.remoteAddress,
>('/typedrequest', 'getActiveConnections'); localAddress: 'server',
startTime: conn.startTime,
const connectionsResponse = await connectionsRequest.fire({ protocol: conn.protocol as any,
identity: context.identity, state: conn.state as any,
}); bytesReceived: conn.bytesIn,
bytesSent: conn.bytesOut,
}));
networkStatePart.setState({ networkStatePart.setState({
...networkStatePart.getState()!, ...networkStatePart.getState()!,
connections: connectionsResponse.connections, connections,
connectionsByIP, connectionsByIP,
throughputRate: { throughputRate: {
bytesInPerSecond: network.totalBandwidth.in, bytesInPerSecond: network.totalBandwidth.in,
bytesOutPerSecond: network.totalBandwidth.out bytesOutPerSecond: network.totalBandwidth.out,
}, },
totalBytes: network.totalBytes || { in: 0, out: 0 }, totalBytes: network.totalBytes || { in: 0, out: 0 },
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })), topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.connections })),
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })), topIPsByBandwidth: (network.topEndpointsByBandwidth || []).map(e => ({
throughputHistory: network.throughputHistory || [], ip: e.endpoint,
requestsPerSecond: network.requestsPerSecond || 0, count: e.connections,
requestsTotal: network.requestsTotal || 0, bwIn: e.bandwidth?.in || 0,
backends: network.backends || [], bwOut: e.bandwidth?.out || 0,
frontendProtocols: network.frontendProtocols || null, })),
backendProtocols: network.backendProtocols || null, throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
lastUpdated: Date.now(), domainActivity: network.domainActivity || [],
isLoading: false, throughputHistory: network.throughputHistory || [],
error: null, requestsPerSecond: network.requestsPerSecond || 0,
}); requestsTotal: network.requestsTotal || 0,
} catch (error: unknown) { backends: network.backends || [],
console.error('Failed to fetch connections:', error); frontendProtocols: network.frontendProtocols || null,
networkStatePart.setState({ backendProtocols: network.backendProtocols || null,
...networkStatePart.getState()!, lastUpdated: Date.now(),
connections: [], isLoading: false,
connectionsByIP, error: null,
throughputRate: { });
bytesInPerSecond: network.totalBandwidth.in,
bytesOutPerSecond: network.totalBandwidth.out
},
totalBytes: network.totalBytes || { in: 0, out: 0 },
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })),
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0,
backends: network.backends || [],
frontendProtocols: network.frontendProtocols || null,
backendProtocols: network.backendProtocols || null,
lastUpdated: Date.now(),
isLoading: false,
error: null,
});
}
} }
// Refresh certificate data if on Domains > Certificates subview // Refresh certificate data if on Domains > Certificates subview

View File

@@ -323,16 +323,14 @@ export class OpsViewDomains extends DeesElement {
// Build target options based on current source // Build target options based on current source
const targetOptions: { option: string; key: string }[] = []; const targetOptions: { option: string; key: string }[] = [];
if (domain.source === 'provider') {
targetOptions.push({ option: 'DcRouter (authoritative)', key: 'dcrouter' });
}
// Add all providers (except the current one if already provider-managed)
for (const p of providers) { for (const p of providers) {
if (domain.source === 'provider' && domain.providerId === p.id) continue; // Skip current source
targetOptions.push({ option: `${p.name} (${p.type})`, key: `provider:${p.id}` }); if (p.builtIn && domain.source === 'dcrouter') continue;
} if (!p.builtIn && domain.source === 'provider' && domain.providerId === p.id) continue;
if (domain.source === 'dcrouter') {
targetOptions.unshift({ option: 'DcRouter (authoritative)', key: 'dcrouter' }); const label = p.builtIn ? 'DcRouter (self)' : `${p.name} (${p.type})`;
const key = p.builtIn ? 'dcrouter' : `provider:${p.id}`;
targetOptions.push({ option: label, key });
} }
if (targetOptions.length === 0) { if (targetOptions.length === 0) {
@@ -345,7 +343,7 @@ export class OpsViewDomains extends DeesElement {
} }
const currentLabel = domain.source === 'dcrouter' const currentLabel = domain.source === 'dcrouter'
? 'DcRouter (authoritative)' ? 'DcRouter (self)'
: providers.find((p) => p.id === domain.providerId)?.name || 'Provider'; : providers.find((p) => p.id === domain.providerId)?.name || 'Provider';
DeesModal.createAndShow({ DeesModal.createAndShow({

View File

@@ -10,22 +10,6 @@ declare global {
} }
} }
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') @customElement('ops-view-network-activity')
export class OpsViewNetworkActivity extends DeesElement { export class OpsViewNetworkActivity extends DeesElement {
/** How far back the traffic chart shows */ /** How far back the traffic chart shows */
@@ -42,9 +26,6 @@ export class OpsViewNetworkActivity extends DeesElement {
accessor networkState = appstate.networkStatePart.getState()!; accessor networkState = appstate.networkStatePart.getState()!;
@state()
accessor networkRequests: INetworkRequest[] = [];
@state() @state()
accessor trafficDataIn: Array<{ x: string | number; y: number }> = []; accessor trafficDataIn: Array<{ x: string | number; y: number }> = [];
@@ -314,108 +295,21 @@ export class OpsViewNetworkActivity extends DeesElement {
<!-- Protocol Distribution Charts --> <!-- Protocol Distribution Charts -->
${this.renderProtocolCharts()} ${this.renderProtocolCharts()}
<!-- Top IPs Section --> <!-- Top IPs by Connection Count -->
${this.renderTopIPs()} ${this.renderTopIPs()}
<!-- Top IPs by Bandwidth -->
${this.renderTopIPsByBandwidth()}
<!-- Domain Activity -->
${this.renderDomainActivity()}
<!-- Backend Protocols Section --> <!-- Backend Protocols Section -->
${this.renderBackendProtocols()} ${this.renderBackendProtocols()}
<!-- Requests Table -->
<dees-table
.data=${this.networkRequests}
.rowKey=${'id'}
.highlightUpdates=${'flash'}
.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
.showColumnFilters=${true}
.pagination=${true}
.paginationSize=${50}
dataName="request"
></dees-table>
</div> </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 { private formatNumber(num: number): string {
if (num >= 1000000) { if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'; return (num / 1000000).toFixed(1) + 'M';
@@ -619,6 +513,66 @@ export class OpsViewNetworkActivity extends DeesElement {
`; `;
} }
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,
};
}}
heading1="Top IPs by Bandwidth"
heading2="IPs with highest throughput"
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,
'Routes': item.routeCount,
};
}}
heading1="Domain Activity"
heading2="Per-domain network activity aggregated from route metrics"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="domain"
></dees-table>
`;
}
private renderBackendProtocols(): TemplateResult { private renderBackendProtocols(): TemplateResult {
const backends = this.networkState.backends; const backends = this.networkState.backends;
if (!backends || backends.length === 0) { if (!backends || backends.length === 0) {
@@ -730,25 +684,6 @@ export class OpsViewNetworkActivity extends DeesElement {
this.requestsPerSecHistory.shift(); this.requestsPerSecHistory.shift();
} }
// Reassign unconditionally so dees-table's flash diff can compare per-cell
// values against the previous snapshot. Row identity is preserved via
// rowKey='id', so DOM nodes are reused across ticks.
this.networkRequests = this.networkState.connections.map((conn) => ({
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',
}));
// Load server-side throughput history into chart (once) // Load server-side throughput history into chart (once)
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) { if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
this.loadThroughputHistory(); this.loadThroughputHistory();