From b3751abd17dc0f2b91c1e9e35949b2a0b7469aa6 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 25 Apr 2026 20:37:28 +0000 Subject: [PATCH] feat(monitoring): improve network activity metrics with live domain request rates and backend identifiers --- changelog.md | 7 + package.json | 2 +- pnpm-lock.yaml | 10 +- test/test.email-ops-api.ts | 8 +- test/test.errors.ts | 9 ++ test/test.jwt-auth.ts | 38 ++++-- test/test.metricsmanager.route-keys.node.ts | 126 +++++++++++++++++- test/test.opsserver-api.ts | 6 +- test/test.protected-endpoint.ts | 6 +- test/test.source-profiles-api.ts | 6 +- ts/00_commitinfo_data.ts | 2 +- ts/monitoring/classes.metricsmanager.ts | 111 +++++++++------ ts/opsserver/handlers/security.handler.ts | 16 ++- ts/opsserver/handlers/stats.handler.ts | 1 + ts_interfaces/data/stats.ts | 12 +- ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 41 +++--- .../network/ops-view-network-activity.ts | 8 +- 18 files changed, 318 insertions(+), 93 deletions(-) diff --git a/changelog.md b/changelog.md index be69f6c..7b5d761 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-25 - 13.21.0 - feat(monitoring) +improve network activity metrics with live domain request rates and backend identifiers + +- use SmartProxy per-domain live request rates to rank and attribute domain activity metrics, while retaining lifetime request totals as fallback data +- separate aggregate backend rows from protocol cache rows with stable ids so cached protocol entries no longer duplicate active backend connection counts +- expose frontend and backend protocol distributions plus aggregated connectionCount fields through ops and web network views + ## 2026-04-17 - 13.20.2 - fix(vpn) handle VPN forwarding mode downgrades and support runtime VPN config updates diff --git a/package.json b/package.json index 0543e19..8134219 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@push.rocks/smartnetwork": "^4.6.0", "@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpromise": "^4.2.3", - "@push.rocks/smartproxy": "^27.7.4", + "@push.rocks/smartproxy": "^27.8.0", "@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrx": "^3.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c44ec5b..7d313b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,8 +81,8 @@ importers: specifier: ^4.2.3 version: 4.2.3 '@push.rocks/smartproxy': - specifier: ^27.7.4 - version: 27.7.4 + specifier: ^27.8.0 + version: 27.8.0 '@push.rocks/smartradius': specifier: ^1.1.1 version: 1.1.1 @@ -1284,8 +1284,8 @@ packages: '@push.rocks/smartpromise@4.2.3': resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} - '@push.rocks/smartproxy@27.7.4': - resolution: {integrity: sha512-WY9Jp6Jtqo5WbW29XpATuxzGyLs8LGkAlrycgMN/IdYfvgtEB2HWuztBZCDLFMuD3Qnv4vVdci9s0nF0ZPyJcQ==} + '@push.rocks/smartproxy@27.8.0': + resolution: {integrity: sha512-/+rfSAz9hRopuRRBwaI/VVtFTLNemnh9RIf0YAPRhrLCL4WGJXkjnpX4Zi6W1AAPDU2wlz7Zm0F6TO+nXLqP5w==} '@push.rocks/smartpuppeteer@2.0.5': resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} @@ -6512,7 +6512,7 @@ snapshots: '@push.rocks/smartpromise@4.2.3': {} - '@push.rocks/smartproxy@27.7.4': + '@push.rocks/smartproxy@27.8.0': dependencies: '@push.rocks/smartcrypto': 2.0.4 '@push.rocks/smartlog': 3.2.2 diff --git a/test/test.email-ops-api.ts b/test/test.email-ops-api.ts index d73ffb2..8bd0e1e 100644 --- a/test/test.email-ops-api.ts +++ b/test/test.email-ops-api.ts @@ -101,7 +101,13 @@ tap.test('should login as admin for email API tests', async () => { password: 'admin', }); - adminIdentity = response.identity; + const responseIdentity = response.identity; + expect(responseIdentity).toBeDefined(); + if (!responseIdentity) { + throw new Error('Expected admin login response to include identity'); + } + + adminIdentity = responseIdentity; expect(adminIdentity.jwt).toBeTruthy(); }); diff --git a/test/test.errors.ts b/test/test.errors.ts index 0cebd83..c1b365a 100644 --- a/test/test.errors.ts +++ b/test/test.errors.ts @@ -103,6 +103,9 @@ tap.test('ErrorHandler should properly handle and format errors', async () => { }, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' }); } catch (error) { expect(error).toBeInstanceOf(PlatformError); + if (!(error instanceof PlatformError)) { + throw error; + } expect(error.code).toEqual('TEST_EXECUTION_ERROR'); expect(error.context.operation).toEqual('testExecution'); } @@ -197,6 +200,9 @@ tap.test('Error retry utilities should work correctly', async () => { } ); } catch (error) { + if (!(error instanceof Error)) { + throw error; + } expect(error.message).toEqual('Critical error'); expect(attempts).toEqual(1); // Should only attempt once } @@ -262,6 +268,9 @@ tap.test('Error handling can be combined with retry for robust operations', asyn // Should not reach here expect(false).toEqual(true); } catch (error) { + if (!(error instanceof Error)) { + throw error; + } expect(error.message).toContain('Flaky failure'); expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts } diff --git a/test/test.jwt-auth.ts b/test/test.jwt-auth.ts index 3a31794..5dc909e 100644 --- a/test/test.jwt-auth.ts +++ b/test/test.jwt-auth.ts @@ -27,16 +27,20 @@ tap.test('should login with admin credentials and receive JWT', async () => { username: 'admin', password: 'admin' }); - + expect(response).toHaveProperty('identity'); - expect(response.identity).toHaveProperty('jwt'); - expect(response.identity).toHaveProperty('userId'); - expect(response.identity).toHaveProperty('name'); - expect(response.identity).toHaveProperty('expiresAt'); - expect(response.identity).toHaveProperty('role'); - expect(response.identity.role).toEqual('admin'); - - identity = response.identity; + const responseIdentity = response.identity; + if (!responseIdentity) { + throw new Error('Expected admin login response to include identity'); + } + expect(responseIdentity).toHaveProperty('jwt'); + expect(responseIdentity).toHaveProperty('userId'); + expect(responseIdentity).toHaveProperty('name'); + expect(responseIdentity).toHaveProperty('expiresAt'); + expect(responseIdentity).toHaveProperty('role'); + expect(responseIdentity.role).toEqual('admin'); + + identity = responseIdentity; console.log('JWT:', identity.jwt); }); @@ -53,7 +57,11 @@ tap.test('should verify valid JWT identity', async () => { expect(response).toHaveProperty('valid'); expect(response.valid).toBeTrue(); expect(response).toHaveProperty('identity'); - expect(response.identity.userId).toEqual(identity.userId); + const responseIdentity = response.identity; + if (!responseIdentity) { + throw new Error('Expected verify response to include identity'); + } + expect(responseIdentity.userId).toEqual(identity.userId); }); tap.test('should reject invalid JWT', async () => { @@ -86,8 +94,12 @@ tap.test('should verify JWT matches identity data', async () => { expect(response).toHaveProperty('valid'); expect(response.valid).toBeTrue(); - expect(response.identity.expiresAt).toEqual(identity.expiresAt); - expect(response.identity.userId).toEqual(identity.userId); + const responseIdentity = response.identity; + if (!responseIdentity) { + throw new Error('Expected verify response to include identity'); + } + expect(responseIdentity.expiresAt).toEqual(identity.expiresAt); + expect(responseIdentity.userId).toEqual(identity.userId); }); tap.test('should handle logout', async () => { @@ -129,4 +141,4 @@ tap.test('should stop DCRouter', async () => { await testDcRouter.stop(); }); -export default tap.start(); \ No newline at end of file +export default tap.start(); diff --git a/test/test.metricsmanager.route-keys.node.ts b/test/test.metricsmanager.route-keys.node.ts index 05c2875..796a7ad 100644 --- a/test/test.metricsmanager.route-keys.node.ts +++ b/test/test.metricsmanager.route-keys.node.ts @@ -18,6 +18,9 @@ function createProxyMetrics(args: { connectionsByRoute: Map; throughputByRoute: Map; domainRequestsByIP: Map>; + domainRequestRates?: Map; + backendMetrics?: Map; + protocolCache?: any[]; requestsTotal?: number; }) { return { @@ -45,6 +48,7 @@ function createProxyMetrics(args: { perSecond: () => 0, perMinute: () => 0, total: () => args.requestsTotal || 0, + byDomain: () => args.domainRequestRates || new Map(), }, totals: { bytesIn: () => 0, @@ -52,10 +56,10 @@ function createProxyMetrics(args: { connections: () => 0, }, backends: { - byBackend: () => new Map(), + byBackend: () => args.backendMetrics || new Map(), protocols: () => new Map(), topByErrors: () => [], - detectedProtocols: () => [], + detectedProtocols: () => args.protocolCache || [], }, }; } @@ -117,4 +121,122 @@ tap.test('MetricsManager joins domain activity to id-keyed route metrics', async expect(beta!.bytesOutPerSecond).toEqual(600); }); +tap.test('MetricsManager prefers live domain request rates for current activity', async () => { + const proxyMetrics = createProxyMetrics({ + connectionsByRoute: new Map([ + ['route-id-only', 10], + ]), + throughputByRoute: new Map([ + ['route-id-only', { in: 1000, out: 1000 }], + ]), + domainRequestsByIP: new Map([ + ['192.0.2.10', new Map([ + ['alpha.example.com', 1000], + ['beta.example.com', 1], + ])], + ]), + domainRequestRates: new Map([ + ['alpha.example.com', { perSecond: 0, lastMinute: 0 }], + ['beta.example.com', { perSecond: 5, lastMinute: 60 }], + ]), + }); + + const smartProxy = { + getMetrics: () => proxyMetrics, + routeManager: { + getRoutes: () => [ + { + id: 'route-id-only', + match: { + ports: [443], + domains: ['alpha.example.com', 'beta.example.com'], + }, + action: { + type: 'forward', + targets: [{ host: '127.0.0.1', port: 8443 }], + }, + }, + ], + }, + }; + + const manager = new MetricsManager({ smartProxy } as any); + const stats = await manager.getNetworkStats(); + const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com'); + const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com'); + + expect(alpha!.activeConnections).toEqual(0); + expect(alpha!.requestsPerSecond).toEqual(0); + expect(beta!.activeConnections).toEqual(10); + expect(beta!.requestsPerSecond).toEqual(5); + expect(beta!.bytesInPerSecond).toEqual(1000); +}); + +tap.test('MetricsManager does not duplicate backend active counts onto protocol cache rows', async () => { + const proxyMetrics = createProxyMetrics({ + connectionsByRoute: new Map(), + throughputByRoute: new Map(), + domainRequestsByIP: new Map(), + backendMetrics: new Map([ + ['192.0.2.1:443', { + protocol: 'h2', + activeConnections: 257, + totalConnections: 1000, + connectErrors: 1, + handshakeErrors: 2, + requestErrors: 3, + avgConnectTimeMs: 4, + poolHitRate: 0.9, + h2Failures: 5, + }], + ]), + protocolCache: [ + { + host: '192.0.2.1', + port: 443, + domain: 'alpha.example.com', + protocol: 'h2', + h2Suppressed: false, + h3Suppressed: false, + h2CooldownRemainingSecs: null, + h3CooldownRemainingSecs: null, + h2ConsecutiveFailures: null, + h3ConsecutiveFailures: null, + h3Port: null, + ageSecs: 1, + }, + { + host: '192.0.2.1', + port: 443, + domain: 'beta.example.com', + protocol: 'h2', + h2Suppressed: false, + h3Suppressed: false, + h2CooldownRemainingSecs: null, + h3CooldownRemainingSecs: null, + h2ConsecutiveFailures: null, + h3ConsecutiveFailures: null, + h3Port: null, + ageSecs: 1, + }, + ], + }); + + const smartProxy = { + getMetrics: () => proxyMetrics, + routeManager: { + getRoutes: () => [], + }, + }; + + const manager = new MetricsManager({ smartProxy } as any); + const stats = await manager.getNetworkStats(); + const aggregate = stats.backends.find((item) => item.id === 'backend:192.0.2.1:443'); + const cacheRows = stats.backends.filter((item) => item.id?.startsWith('cache:')); + + expect(aggregate!.activeConnections).toEqual(257); + expect(cacheRows.length).toEqual(2); + expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue(); +}); + export default tap.start(); diff --git a/test/test.opsserver-api.ts b/test/test.opsserver-api.ts index c6318c6..de861e3 100644 --- a/test/test.opsserver-api.ts +++ b/test/test.opsserver-api.ts @@ -29,7 +29,11 @@ tap.test('should login as admin', async () => { }); expect(response).toHaveProperty('identity'); - adminIdentity = response.identity; + const responseIdentity = response.identity; + if (!responseIdentity) { + throw new Error('Expected admin login response to include identity'); + } + adminIdentity = responseIdentity; }); tap.test('should respond to health status request', async () => { diff --git a/test/test.protected-endpoint.ts b/test/test.protected-endpoint.ts index 72fa655..5e29780 100644 --- a/test/test.protected-endpoint.ts +++ b/test/test.protected-endpoint.ts @@ -29,7 +29,11 @@ tap.test('should login as admin', async () => { }); expect(response).toHaveProperty('identity'); - adminIdentity = response.identity; + const responseIdentity = response.identity; + if (!responseIdentity) { + throw new Error('Expected admin login response to include identity'); + } + adminIdentity = responseIdentity; console.log('Admin logged in with JWT'); }); diff --git a/test/test.source-profiles-api.ts b/test/test.source-profiles-api.ts index b7e8bab..57a02b0 100644 --- a/test/test.source-profiles-api.ts +++ b/test/test.source-profiles-api.ts @@ -35,7 +35,11 @@ tap.test('should login as admin', async () => { }); expect(response).toHaveProperty('identity'); - adminIdentity = response.identity; + const responseIdentity = response.identity; + if (!responseIdentity) { + throw new Error('Expected admin login response to include identity'); + } + adminIdentity = responseIdentity; }); // ============================================================================ diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 9d8a67c..0592ff1 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.20.2', + version: '13.21.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/monitoring/classes.metricsmanager.ts b/ts/monitoring/classes.metricsmanager.ts index f4e2053..e5da11c 100644 --- a/ts/monitoring/classes.metricsmanager.ts +++ b/ts/monitoring/classes.metricsmanager.ts @@ -560,7 +560,9 @@ export class MetricsManager { requestsPerSecond: 0, requestsTotal: 0, backends: [] as Array, - domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number }>, + domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number; requestsPerSecond?: number; requestsLastMinute?: number }>, + frontendProtocols: null, + backendProtocols: null, }; } @@ -592,6 +594,7 @@ export class MetricsManager { // Get HTTP request rates const requestsPerSecond = proxyMetrics.requests.perSecond(); const requestsTotal = proxyMetrics.requests.total(); + const domainRequestRates = proxyMetrics.requests.byDomain(); // Get frontend/backend protocol distribution const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null; @@ -619,47 +622,48 @@ export class MetricsManager { const seenCacheKeys = new Set(); for (const [key, bm] of backendMetrics) { + backends.push({ + id: `backend:${key}`, + backend: key, + domain: null, + protocol: bm.protocol, + activeConnections: bm.activeConnections, + totalConnections: bm.totalConnections, + connectErrors: bm.connectErrors, + handshakeErrors: bm.handshakeErrors, + requestErrors: bm.requestErrors, + avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10, + poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000, + h2Failures: bm.h2Failures, + h2Suppressed: false, + h3Suppressed: false, + h2CooldownRemainingSecs: null, + h3CooldownRemainingSecs: null, + h2ConsecutiveFailures: null, + h3ConsecutiveFailures: null, + h3Port: null, + cacheAgeSecs: null, + }); + const cacheEntries = cacheByBackend.get(key); - if (!cacheEntries || cacheEntries.length === 0) { - // No protocol cache entry — emit one row with backend metrics only - backends.push({ - backend: key, - domain: null, - protocol: bm.protocol, - activeConnections: bm.activeConnections, - totalConnections: bm.totalConnections, - connectErrors: bm.connectErrors, - handshakeErrors: bm.handshakeErrors, - requestErrors: bm.requestErrors, - avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10, - poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000, - h2Failures: bm.h2Failures, - h2Suppressed: false, - h3Suppressed: false, - h2CooldownRemainingSecs: null, - h3CooldownRemainingSecs: null, - h2ConsecutiveFailures: null, - h3ConsecutiveFailures: null, - h3Port: null, - cacheAgeSecs: null, - }); - } else { - // One row per domain, each enriched with the shared backend metrics + if (cacheEntries && cacheEntries.length > 0) { + // Protocol cache rows are domain-scoped metadata, not live backend connections. for (const cache of cacheEntries) { const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`; seenCacheKeys.add(compositeKey); backends.push({ + id: `cache:${compositeKey}`, backend: key, domain: cache.domain ?? null, protocol: cache.protocol ?? bm.protocol, - activeConnections: bm.activeConnections, - totalConnections: bm.totalConnections, - connectErrors: bm.connectErrors, - handshakeErrors: bm.handshakeErrors, - requestErrors: bm.requestErrors, - avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10, - poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000, - h2Failures: bm.h2Failures, + activeConnections: 0, + totalConnections: 0, + connectErrors: 0, + handshakeErrors: 0, + requestErrors: 0, + avgConnectTimeMs: 0, + poolHitRate: 0, + h2Failures: 0, h2Suppressed: cache.h2Suppressed, h3Suppressed: cache.h3Suppressed, h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs, @@ -678,6 +682,7 @@ export class MetricsManager { const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`; if (!seenCacheKeys.has(compositeKey)) { backends.push({ + id: `cache:${compositeKey}`, backend: `${entry.host}:${entry.port}`, domain: entry.domain, protocol: entry.protocol, @@ -750,6 +755,9 @@ export class MetricsManager { // Resolve wildcards using domains seen in request metrics const allKnownDomains = new Set(domainRequestTotals.keys()); + for (const domain of domainRequestRates.keys()) { + allKnownDomains.add(domain); + } for (const entry of protocolCache) { if (entry.domain) allKnownDomains.add(entry.domain); } @@ -775,11 +783,20 @@ export class MetricsManager { } } - // For each route, compute the total request count across all its resolved domains - // so we can distribute throughput/connections proportionally + const hasLiveDomainRates = domainRequestRates.size > 0; + const getDomainWeight = (domain: string): number => { + const liveRate = domainRequestRates.get(domain); + return hasLiveDomainRates + ? (liveRate?.lastMinute ?? 0) + : (domainRequestTotals.get(domain) || 0); + }; + + // For each route, compute the total activity weight across all resolved domains + // so we can distribute route-level throughput/connections. Prefer live domain + // request rates from SmartProxy 27.8+, falling back to lifetime counters. const routeTotalRequests = new Map(); for (const [domain, routeKeys] of domainToRoutes) { - const reqs = domainRequestTotals.get(domain) || 0; + const reqs = getDomainWeight(domain); for (const routeKey of routeKeys) { routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs); } @@ -792,10 +809,13 @@ export class MetricsManager { bytesOutPerSec: number; routeCount: number; requestCount: number; + requestsPerSecond: number; + requestsLastMinute: number; }>(); for (const [domain, routeKeys] of domainToRoutes) { - const domainReqs = domainRequestTotals.get(domain) || 0; + const domainReqs = getDomainWeight(domain); + const requestRate = domainRequestRates.get(domain); let totalConns = 0; let totalIn = 0; let totalOut = 0; @@ -816,7 +836,9 @@ export class MetricsManager { bytesInPerSec: totalIn, bytesOutPerSec: totalOut, routeCount: routeKeys.length, - requestCount: domainReqs, + requestCount: domainRequestTotals.get(domain) || 0, + requestsPerSecond: requestRate?.perSecond ?? 0, + requestsLastMinute: requestRate?.lastMinute ?? 0, }); } @@ -828,8 +850,17 @@ export class MetricsManager { activeConnections: data.activeConnections, routeCount: data.routeCount, requestCount: data.requestCount, + requestsPerSecond: data.requestsPerSecond, + requestsLastMinute: data.requestsLastMinute, })) - .sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond)); + .sort((a, b) => { + if (hasLiveDomainRates) { + return (b.requestsPerSecond - a.requestsPerSecond) || + (b.requestsLastMinute - a.requestsLastMinute) || + ((b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond)); + } + return (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond); + }); return { connectionsByIP, diff --git a/ts/opsserver/handlers/security.handler.ts b/ts/opsserver/handlers/security.handler.ts index f48b2bf..43d82e8 100644 --- a/ts/opsserver/handlers/security.handler.ts +++ b/ts/opsserver/handlers/security.handler.ts @@ -50,19 +50,21 @@ export class SecurityHandler { localAddress: conn.destination.ip, startTime: conn.startTime, protocol: conn.type === 'http' ? 'https' : conn.type as any, - state: conn.status as any, + state: conn.status === 'active' ? 'connected' : conn.status as any, bytesReceived: (conn as any)._throughputIn || 0, bytesSent: (conn as any)._throughputOut || 0, + connectionCount: conn.bytesTransferred || 1, })); + const totalConnections = connectionInfos.reduce((sum, conn) => sum + (conn.connectionCount || 1), 0); const summary = { - total: connectionInfos.length, + total: totalConnections, byProtocol: connectionInfos.reduce((acc, conn) => { - acc[conn.protocol] = (acc[conn.protocol] || 0) + 1; + acc[conn.protocol] = (acc[conn.protocol] || 0) + (conn.connectionCount || 1); return acc; }, {} as { [protocol: string]: number }), byState: connectionInfos.reduce((acc, conn) => { - acc[conn.state] = (acc[conn.state] || 0) + 1; + acc[conn.state] = (acc[conn.state] || 0) + (conn.connectionCount || 1); return acc; }, {} as { [state: string]: number }), }; @@ -104,6 +106,8 @@ export class SecurityHandler { requestsPerSecond: networkStats.requestsPerSecond || 0, requestsTotal: networkStats.requestsTotal || 0, backends: networkStats.backends || [], + frontendProtocols: networkStats.frontendProtocols || null, + backendProtocols: networkStats.backendProtocols || null, }; } @@ -120,6 +124,8 @@ export class SecurityHandler { requestsPerSecond: 0, requestsTotal: 0, backends: [], + frontendProtocols: null, + backendProtocols: null, }; } ) @@ -335,4 +341,4 @@ export class SecurityHandler { limits: [], }; } -} \ No newline at end of file +} diff --git a/ts/opsserver/handlers/stats.handler.ts b/ts/opsserver/handlers/stats.handler.ts index 40969ff..9e9b8f1 100644 --- a/ts/opsserver/handlers/stats.handler.ts +++ b/ts/opsserver/handlers/stats.handler.ts @@ -302,6 +302,7 @@ export class StatsHandler { startTime: 0, bytesIn: tp?.in || 0, bytesOut: tp?.out || 0, + connectionCount: count, }); } diff --git a/ts_interfaces/data/stats.ts b/ts_interfaces/data/stats.ts index cbec962..9e69fd1 100644 --- a/ts_interfaces/data/stats.ts +++ b/ts_interfaces/data/stats.ts @@ -119,6 +119,8 @@ export interface IConnectionInfo { state: 'connecting' | 'connected' | 'authenticated' | 'transmitting' | 'closing'; bytesReceived: number; bytesSent: number; + /** Present when the row is an aggregate, e.g. one row per remote IP. */ + connectionCount?: number; } export interface IQueueStatus { @@ -149,7 +151,12 @@ export interface IDomainActivity { bytesOutPerSecond: number; activeConnections: number; routeCount: number; + /** Lifetime request count when available from SmartProxy. */ requestCount: number; + /** Live HTTP request rate when SmartProxy exposes per-domain rates. */ + requestsPerSecond?: number; + /** HTTP requests over the last minute when SmartProxy exposes per-domain rates. */ + requestsLastMinute?: number; } export interface INetworkMetrics { @@ -208,9 +215,12 @@ export interface IConnectionDetails { startTime: number; bytesIn: number; bytesOut: number; + /** Present when the row is an aggregate, e.g. one row per remote IP. */ + connectionCount?: number; } export interface IBackendInfo { + id?: string; backend: string; domain: string | null; protocol: string; @@ -250,4 +260,4 @@ export interface IVpnStats { registeredClients: number; connectedClients: number; wgListenPort: number; -} \ No newline at end of file +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 9d8a67c..0592ff1 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.20.2', + version: '13.21.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index a799d15..212088d 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -512,15 +512,6 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat if (!context.identity) return currentState; try { - // Fetch active connections using the existing endpoint - const connectionsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< - interfaces.requests.IReq_GetActiveConnections - >('/typedrequest', 'getActiveConnections'); - - const connectionsResponse = await connectionsRequest.fire({ - identity: context.identity, - }); - // Get network stats for throughput and IP data const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_GetNetworkStats @@ -533,22 +524,35 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat // Use the connections data for the connection list // and network stats for throughput and IP analytics const connectionsByIP: { [ip: string]: number } = {}; + const throughputByIP = new Map(); + for (const item of networkStatsResponse.throughputByIP || []) { + throughputByIP.set(item.ip, { in: item.in, out: item.out }); + } // Build connectionsByIP from network stats if available if (networkStatsResponse.connectionsByIP && Array.isArray(networkStatsResponse.connectionsByIP)) { networkStatsResponse.connectionsByIP.forEach((item: { ip: string; count: number }) => { connectionsByIP[item.ip] = item.count; }); - } else { - // Fallback: calculate from connections - connectionsResponse.connections.forEach(conn => { - const ip = conn.remoteAddress; - connectionsByIP[ip] = (connectionsByIP[ip] || 0) + 1; - }); } + const connections: interfaces.data.IConnectionInfo[] = Object.entries(connectionsByIP).map(([ip, count]) => { + const tp = throughputByIP.get(ip); + return { + id: `ip-${ip}`, + remoteAddress: ip, + localAddress: 'server', + startTime: 0, + protocol: 'https', + state: 'connected', + bytesReceived: tp?.in || 0, + bytesSent: tp?.out || 0, + connectionCount: count, + }; + }); + return { - connections: connectionsResponse.connections, + connections, connectionsByIP, throughputRate: networkStatsResponse.throughputRate || { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, totalBytes: networkStatsResponse.totalDataTransferred @@ -2589,7 +2593,7 @@ async function dispatchCombinedRefreshActionInner() { email: true, dns: true, security: true, - network: currentView === 'network', // Only fetch network if on network view + network: currentView === 'network' && currentSubview === 'activity', radius: true, vpn: true, }, @@ -2617,7 +2621,7 @@ async function dispatchCombinedRefreshActionInner() { // Build connectionsByIP from connectionDetails (now populated with real per-IP data) network.connectionDetails.forEach(conn => { - connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1; + connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + (conn.connectionCount || 1); }); // Build connections from connectionDetails (real per-IP aggregates) @@ -2630,6 +2634,7 @@ async function dispatchCombinedRefreshActionInner() { state: conn.state as any, bytesReceived: conn.bytesIn, bytesSent: conn.bytesOut, + connectionCount: conn.connectionCount, })); networkStatePart.setState({ diff --git a/ts_web/elements/network/ops-view-network-activity.ts b/ts_web/elements/network/ops-view-network-activity.ts index 45650ba..25ee2a8 100644 --- a/ts_web/elements/network/ops-view-network-activity.ts +++ b/ts_web/elements/network/ops-view-network-activity.ts @@ -79,7 +79,6 @@ export class OpsViewNetworkActivity extends DeesElement { // Subscribe and track unsubscribe functions const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => { this.statsState = state; - this.updateNetworkData(); }); this.rxSubscriptions.push(statsUnsubscribe); @@ -560,6 +559,8 @@ export class OpsViewNetworkActivity extends DeesElement { '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, }; @@ -583,7 +584,7 @@ export class OpsViewNetworkActivity extends DeesElement { return html` { const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors; @@ -707,6 +708,9 @@ export class OpsViewNetworkActivity extends DeesElement { } 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;