fix(monitoring): align domain activity metrics with id-keyed route data

This commit is contained in:
2026-04-14 09:33:41 +00:00
parent 20ea0ce683
commit 58fbc2b1e4
8 changed files with 157 additions and 28 deletions

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2026-04-14 - 13.17.9 - fix(monitoring)
align domain activity metrics with id-keyed route data
- Use route id as a fallback canonical key when matching route metrics to configured domains in MetricsManager.
- Add a regression test covering domain activity aggregation for routes identified only by id.
- Update the network activity UI to show formatted total connection counts in the active connections card.
- Bump @push.rocks/smartproxy from ^27.7.3 to ^27.7.4.
## 2026-04-14 - 13.17.8 - fix(opsserver) ## 2026-04-14 - 13.17.8 - fix(opsserver)
align certificate status handling with the updated smartproxy response format align certificate status handling with the updated smartproxy response format

View File

@@ -54,7 +54,7 @@
"@push.rocks/smartnetwork": "^4.6.0", "@push.rocks/smartnetwork": "^4.6.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.7.3", "@push.rocks/smartproxy": "^27.7.4",
"@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",

10
pnpm-lock.yaml generated
View File

@@ -81,8 +81,8 @@ importers:
specifier: ^4.2.3 specifier: ^4.2.3
version: 4.2.3 version: 4.2.3
'@push.rocks/smartproxy': '@push.rocks/smartproxy':
specifier: ^27.7.3 specifier: ^27.7.4
version: 27.7.3 version: 27.7.4
'@push.rocks/smartradius': '@push.rocks/smartradius':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
@@ -1284,8 +1284,8 @@ packages:
'@push.rocks/smartpromise@4.2.3': '@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@27.7.3': '@push.rocks/smartproxy@27.7.4':
resolution: {integrity: sha512-1NER+2nM2GWzrMB1xoCzkV9in8a1gWNwOimgE/f2kPYnhy5DsA2XX/KiLGJ0ge3cpCVgoK1nNe3byXQHWvmPbA==} resolution: {integrity: sha512-WY9Jp6Jtqo5WbW29XpATuxzGyLs8LGkAlrycgMN/IdYfvgtEB2HWuztBZCDLFMuD3Qnv4vVdci9s0nF0ZPyJcQ==}
'@push.rocks/smartpuppeteer@2.0.5': '@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -6506,7 +6506,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {} '@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@27.7.3': '@push.rocks/smartproxy@27.7.4':
dependencies: dependencies:
'@push.rocks/smartcrypto': 2.0.4 '@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2 '@push.rocks/smartlog': 3.2.2

View File

@@ -0,0 +1,120 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { MetricsManager } from '../ts/monitoring/classes.metricsmanager.js';
const emptyProtocolDistribution = {
h1Active: 0,
h1Total: 0,
h2Active: 0,
h2Total: 0,
h3Active: 0,
h3Total: 0,
wsActive: 0,
wsTotal: 0,
otherActive: 0,
otherTotal: 0,
};
function createProxyMetrics(args: {
connectionsByRoute: Map<string, number>;
throughputByRoute: Map<string, { in: number; out: number }>;
domainRequestsByIP: Map<string, Map<string, number>>;
requestsTotal?: number;
}) {
return {
connections: {
active: () => 0,
total: () => 0,
byRoute: () => args.connectionsByRoute,
byIP: () => new Map<string, number>(),
topIPs: () => [],
domainRequestsByIP: () => args.domainRequestsByIP,
topDomainRequests: () => [],
frontendProtocols: () => emptyProtocolDistribution,
backendProtocols: () => emptyProtocolDistribution,
},
throughput: {
instant: () => ({ in: 0, out: 0 }),
recent: () => ({ in: 0, out: 0 }),
average: () => ({ in: 0, out: 0 }),
custom: () => ({ in: 0, out: 0 }),
history: () => [],
byRoute: () => args.throughputByRoute,
byIP: () => new Map<string, { in: number; out: number }>(),
},
requests: {
perSecond: () => 0,
perMinute: () => 0,
total: () => args.requestsTotal || 0,
},
totals: {
bytesIn: () => 0,
bytesOut: () => 0,
connections: () => 0,
},
backends: {
byBackend: () => new Map<string, any>(),
protocols: () => new Map<string, string>(),
topByErrors: () => [],
detectedProtocols: () => [],
},
};
}
tap.test('MetricsManager joins domain activity to id-keyed route metrics', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map([
['route-id-only', 4],
]),
throughputByRoute: new Map([
['route-id-only', { in: 1200, out: 2400 }],
]),
domainRequestsByIP: new Map([
['192.0.2.10', new Map([
['alpha.example.com', 3],
['beta.example.com', 1],
])],
]),
requestsTotal: 4,
});
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).toBeDefined();
expect(beta).toBeDefined();
expect(alpha!.requestCount).toEqual(3);
expect(alpha!.routeCount).toEqual(1);
expect(alpha!.activeConnections).toEqual(3);
expect(alpha!.bytesInPerSecond).toEqual(900);
expect(alpha!.bytesOutPerSecond).toEqual(1800);
expect(beta!.requestCount).toEqual(1);
expect(beta!.routeCount).toEqual(1);
expect(beta!.activeConnections).toEqual(1);
expect(beta!.bytesInPerSecond).toEqual(300);
expect(beta!.bytesOutPerSecond).toEqual(600);
});
export default tap.start();

View File

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

View File

@@ -733,16 +733,17 @@ export class MetricsManager {
} }
} }
// Map route name → domains from route config // Map canonical route key → domains from route config
const routeDomains = new Map<string, string[]>(); const routeDomains = new Map<string, string[]>();
if (this.dcRouter.smartProxy) { if (this.dcRouter.smartProxy) {
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) { for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
if (!route.name || !route.match.domains) continue; const routeKey = route.name || route.id;
if (!routeKey || !route.match.domains) continue;
const domains = Array.isArray(route.match.domains) const domains = Array.isArray(route.match.domains)
? route.match.domains ? route.match.domains
: [route.match.domains]; : [route.match.domains];
if (domains.length > 0) { if (domains.length > 0) {
routeDomains.set(route.name, domains); routeDomains.set(routeKey, domains);
} }
} }
} }
@@ -753,23 +754,23 @@ export class MetricsManager {
if (entry.domain) allKnownDomains.add(entry.domain); if (entry.domain) allKnownDomains.add(entry.domain);
} }
// Build reverse map: concrete domain → route name(s) // Build reverse map: concrete domain → canonical route key(s)
const domainToRoutes = new Map<string, string[]>(); const domainToRoutes = new Map<string, string[]>();
for (const [routeName, domains] of routeDomains) { for (const [routeKey, domains] of routeDomains) {
for (const pattern of domains) { for (const pattern of domains) {
if (pattern.includes('*')) { if (pattern.includes('*')) {
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$'); const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
for (const knownDomain of allKnownDomains) { for (const knownDomain of allKnownDomains) {
if (regex.test(knownDomain)) { if (regex.test(knownDomain)) {
const existing = domainToRoutes.get(knownDomain); const existing = domainToRoutes.get(knownDomain);
if (existing) { existing.push(routeName); } if (existing) { existing.push(routeKey); }
else { domainToRoutes.set(knownDomain, [routeName]); } else { domainToRoutes.set(knownDomain, [routeKey]); }
} }
} }
} else { } else {
const existing = domainToRoutes.get(pattern); const existing = domainToRoutes.get(pattern);
if (existing) { existing.push(routeName); } if (existing) { existing.push(routeKey); }
else { domainToRoutes.set(pattern, [routeName]); } else { domainToRoutes.set(pattern, [routeKey]); }
} }
} }
} }
@@ -777,10 +778,10 @@ export class MetricsManager {
// For each route, compute the total request count across all its resolved domains // For each route, compute the total request count across all its resolved domains
// so we can distribute throughput/connections proportionally // so we can distribute throughput/connections proportionally
const routeTotalRequests = new Map<string, number>(); const routeTotalRequests = new Map<string, number>();
for (const [domain, routeNames] of domainToRoutes) { for (const [domain, routeKeys] of domainToRoutes) {
const reqs = domainRequestTotals.get(domain) || 0; const reqs = domainRequestTotals.get(domain) || 0;
for (const routeName of routeNames) { for (const routeKey of routeKeys) {
routeTotalRequests.set(routeName, (routeTotalRequests.get(routeName) || 0) + reqs); routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
} }
} }
@@ -793,16 +794,16 @@ export class MetricsManager {
requestCount: number; requestCount: number;
}>(); }>();
for (const [domain, routeNames] of domainToRoutes) { for (const [domain, routeKeys] of domainToRoutes) {
const domainReqs = domainRequestTotals.get(domain) || 0; const domainReqs = domainRequestTotals.get(domain) || 0;
let totalConns = 0; let totalConns = 0;
let totalIn = 0; let totalIn = 0;
let totalOut = 0; let totalOut = 0;
for (const routeName of routeNames) { for (const routeKey of routeKeys) {
const conns = connectionsByRoute.get(routeName) || 0; const conns = connectionsByRoute.get(routeKey) || 0;
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 }; const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
const routeTotal = routeTotalRequests.get(routeName) || 0; const routeTotal = routeTotalRequests.get(routeKey) || 0;
const share = routeTotal > 0 ? domainReqs / routeTotal : 0; const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
totalConns += conns * share; totalConns += conns * share;
@@ -814,7 +815,7 @@ export class MetricsManager {
activeConnections: Math.round(totalConns), activeConnections: Math.round(totalConns),
bytesInPerSec: totalIn, bytesInPerSec: totalIn,
bytesOutPerSec: totalOut, bytesOutPerSec: totalOut,
routeCount: routeNames.length, routeCount: routeKeys.length,
requestCount: domainReqs, requestCount: domainReqs,
}); });
} }
@@ -990,4 +991,4 @@ export class MetricsManager {
return { queries }; return { queries };
} }
} }

View File

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

View File

@@ -374,7 +374,7 @@ export class OpsViewNetworkActivity extends DeesElement {
type: 'number', type: 'number',
icon: 'lucide:Plug', icon: 'lucide:Plug',
color: activeConnections > 100 ? '#f59e0b' : '#22c55e', color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`, description: `Total: ${this.formatNumber(this.statsState.serverStats?.totalConnections || 0)} connections`,
actions: [ actions: [
{ {
name: 'View Details', name: 'View Details',