Compare commits

...

3 Commits

Author SHA1 Message Date
jkunz 1c3aa89f8d v13.21.0
Docker (tags) / security (push) Failing after 10s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-25 20:37:28 +00:00
jkunz b3751abd17 feat(monitoring): improve network activity metrics with live domain request rates and backend identifiers 2026-04-25 20:37:28 +00:00
jkunz 97017ede98 chore(deps): update serve.zone interfaces 2026-04-25 14:01:26 +00:00
18 changed files with 325 additions and 103 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog # 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) ## 2026-04-17 - 13.20.2 - fix(vpn)
handle VPN forwarding mode downgrades and support runtime VPN config updates handle VPN forwarding mode downgrades and support runtime VPN config updates
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "13.20.2", "version": "13.21.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -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.4", "@push.rocks/smartproxy": "^27.8.0",
"@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",
@@ -63,7 +63,7 @@
"@push.rocks/smartvpn": "1.19.2", "@push.rocks/smartvpn": "1.19.2",
"@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4", "@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.4.3",
"@serve.zone/remoteingress": "^4.15.3", "@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0", "@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
+10 -13
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.4 specifier: ^27.8.0
version: 27.7.4 version: 27.8.0
'@push.rocks/smartradius': '@push.rocks/smartradius':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
@@ -108,8 +108,8 @@ importers:
specifier: ^2.12.4 specifier: ^2.12.4
version: 2.12.4(@tiptap/pm@2.27.2) version: 2.12.4(@tiptap/pm@2.27.2)
'@serve.zone/interfaces': '@serve.zone/interfaces':
specifier: ^5.3.0 specifier: ^5.4.3
version: 5.3.0 version: 5.4.3
'@serve.zone/remoteingress': '@serve.zone/remoteingress':
specifier: ^4.15.3 specifier: ^4.15.3
version: 4.15.3 version: 4.15.3
@@ -147,9 +147,6 @@ importers:
'@types/node': '@types/node':
specifier: ^25.6.0 specifier: ^25.6.0
version: 25.6.0 version: 25.6.0
typescript:
specifier: ^6.0.2
version: 6.0.2
packages: packages:
@@ -1287,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.4': '@push.rocks/smartproxy@27.8.0':
resolution: {integrity: sha512-WY9Jp6Jtqo5WbW29XpATuxzGyLs8LGkAlrycgMN/IdYfvgtEB2HWuztBZCDLFMuD3Qnv4vVdci9s0nF0ZPyJcQ==} resolution: {integrity: sha512-/+rfSAz9hRopuRRBwaI/VVtFTLNemnh9RIf0YAPRhrLCL4WGJXkjnpX4Zi6W1AAPDU2wlz7Zm0F6TO+nXLqP5w==}
'@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==}
@@ -1594,8 +1591,8 @@ packages:
'@serve.zone/catalog@2.12.4': '@serve.zone/catalog@2.12.4':
resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==} resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==}
'@serve.zone/interfaces@5.3.0': '@serve.zone/interfaces@5.4.3':
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==} resolution: {integrity: sha512-9ijFhHoC7GYyyAUJbBoDYmcoCmIXTFPiD6fI3x68SWiC0xA+2LG0nOe14D32c1QN9X/3i2Ac5/1sUibfjHsIGg==}
'@serve.zone/remoteingress@4.15.3': '@serve.zone/remoteingress@4.15.3':
resolution: {integrity: sha512-kg/bmR+qcFRFuigTDr5Fao72cb7m/mSkI5APm7KZDKSUYTFuytNoj6KCIE0ICkc3Nh34y8oDwFJsS6oFo64AyQ==} resolution: {integrity: sha512-kg/bmR+qcFRFuigTDr5Fao72cb7m/mSkI5APm7KZDKSUYTFuytNoj6KCIE0ICkc3Nh34y8oDwFJsS6oFo64AyQ==}
@@ -6515,7 +6512,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {} '@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@27.7.4': '@push.rocks/smartproxy@27.8.0':
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
@@ -6930,7 +6927,7 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@serve.zone/interfaces@5.3.0': '@serve.zone/interfaces@5.4.3':
dependencies: dependencies:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartlog-interfaces': 3.0.2 '@push.rocks/smartlog-interfaces': 3.0.2
+7 -1
View File
@@ -101,7 +101,13 @@ tap.test('should login as admin for email API tests', async () => {
password: 'admin', 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(); expect(adminIdentity.jwt).toBeTruthy();
}); });
+9
View File
@@ -103,6 +103,9 @@ tap.test('ErrorHandler should properly handle and format errors', async () => {
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' }); }, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(PlatformError); expect(error).toBeInstanceOf(PlatformError);
if (!(error instanceof PlatformError)) {
throw error;
}
expect(error.code).toEqual('TEST_EXECUTION_ERROR'); expect(error.code).toEqual('TEST_EXECUTION_ERROR');
expect(error.context.operation).toEqual('testExecution'); expect(error.context.operation).toEqual('testExecution');
} }
@@ -197,6 +200,9 @@ tap.test('Error retry utilities should work correctly', async () => {
} }
); );
} catch (error) { } catch (error) {
if (!(error instanceof Error)) {
throw error;
}
expect(error.message).toEqual('Critical error'); expect(error.message).toEqual('Critical error');
expect(attempts).toEqual(1); // Should only attempt once 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 // Should not reach here
expect(false).toEqual(true); expect(false).toEqual(true);
} catch (error) { } catch (error) {
if (!(error instanceof Error)) {
throw error;
}
expect(error.message).toContain('Flaky failure'); expect(error.message).toContain('Flaky failure');
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
} }
+25 -13
View File
@@ -27,16 +27,20 @@ tap.test('should login with admin credentials and receive JWT', async () => {
username: 'admin', username: 'admin',
password: 'admin' password: 'admin'
}); });
expect(response).toHaveProperty('identity'); expect(response).toHaveProperty('identity');
expect(response.identity).toHaveProperty('jwt'); const responseIdentity = response.identity;
expect(response.identity).toHaveProperty('userId'); if (!responseIdentity) {
expect(response.identity).toHaveProperty('name'); throw new Error('Expected admin login response to include identity');
expect(response.identity).toHaveProperty('expiresAt'); }
expect(response.identity).toHaveProperty('role'); expect(responseIdentity).toHaveProperty('jwt');
expect(response.identity.role).toEqual('admin'); expect(responseIdentity).toHaveProperty('userId');
expect(responseIdentity).toHaveProperty('name');
identity = response.identity; expect(responseIdentity).toHaveProperty('expiresAt');
expect(responseIdentity).toHaveProperty('role');
expect(responseIdentity.role).toEqual('admin');
identity = responseIdentity;
console.log('JWT:', identity.jwt); console.log('JWT:', identity.jwt);
}); });
@@ -53,7 +57,11 @@ tap.test('should verify valid JWT identity', async () => {
expect(response).toHaveProperty('valid'); expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue(); expect(response.valid).toBeTrue();
expect(response).toHaveProperty('identity'); 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 () => { 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).toHaveProperty('valid');
expect(response.valid).toBeTrue(); expect(response.valid).toBeTrue();
expect(response.identity.expiresAt).toEqual(identity.expiresAt); const responseIdentity = response.identity;
expect(response.identity.userId).toEqual(identity.userId); 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 () => { tap.test('should handle logout', async () => {
@@ -129,4 +141,4 @@ tap.test('should stop DCRouter', async () => {
await testDcRouter.stop(); await testDcRouter.stop();
}); });
export default tap.start(); export default tap.start();
+124 -2
View File
@@ -18,6 +18,9 @@ function createProxyMetrics(args: {
connectionsByRoute: Map<string, number>; connectionsByRoute: Map<string, number>;
throughputByRoute: Map<string, { in: number; out: number }>; throughputByRoute: Map<string, { in: number; out: number }>;
domainRequestsByIP: Map<string, Map<string, number>>; domainRequestsByIP: Map<string, Map<string, number>>;
domainRequestRates?: Map<string, { perSecond: number; lastMinute: number }>;
backendMetrics?: Map<string, any>;
protocolCache?: any[];
requestsTotal?: number; requestsTotal?: number;
}) { }) {
return { return {
@@ -45,6 +48,7 @@ function createProxyMetrics(args: {
perSecond: () => 0, perSecond: () => 0,
perMinute: () => 0, perMinute: () => 0,
total: () => args.requestsTotal || 0, total: () => args.requestsTotal || 0,
byDomain: () => args.domainRequestRates || new Map<string, { perSecond: number; lastMinute: number }>(),
}, },
totals: { totals: {
bytesIn: () => 0, bytesIn: () => 0,
@@ -52,10 +56,10 @@ function createProxyMetrics(args: {
connections: () => 0, connections: () => 0,
}, },
backends: { backends: {
byBackend: () => new Map<string, any>(), byBackend: () => args.backendMetrics || new Map<string, any>(),
protocols: () => new Map<string, string>(), protocols: () => new Map<string, string>(),
topByErrors: () => [], 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); 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(); export default tap.start();
+5 -1
View File
@@ -29,7 +29,11 @@ tap.test('should login as admin', async () => {
}); });
expect(response).toHaveProperty('identity'); 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 () => { tap.test('should respond to health status request', async () => {
+5 -1
View File
@@ -29,7 +29,11 @@ tap.test('should login as admin', async () => {
}); });
expect(response).toHaveProperty('identity'); 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'); console.log('Admin logged in with JWT');
}); });
+5 -1
View File
@@ -35,7 +35,11 @@ tap.test('should login as admin', async () => {
}); });
expect(response).toHaveProperty('identity'); 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;
}); });
// ============================================================================ // ============================================================================
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.20.2', version: '13.21.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }
+71 -40
View File
@@ -560,7 +560,9 @@ export class MetricsManager {
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; 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 // Get HTTP request rates
const requestsPerSecond = proxyMetrics.requests.perSecond(); const requestsPerSecond = proxyMetrics.requests.perSecond();
const requestsTotal = proxyMetrics.requests.total(); const requestsTotal = proxyMetrics.requests.total();
const domainRequestRates = proxyMetrics.requests.byDomain();
// Get frontend/backend protocol distribution // Get frontend/backend protocol distribution
const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null; const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null;
@@ -619,47 +622,48 @@ export class MetricsManager {
const seenCacheKeys = new Set<string>(); const seenCacheKeys = new Set<string>();
for (const [key, bm] of backendMetrics) { 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); const cacheEntries = cacheByBackend.get(key);
if (!cacheEntries || cacheEntries.length === 0) { if (cacheEntries && cacheEntries.length > 0) {
// No protocol cache entry — emit one row with backend metrics only // Protocol cache rows are domain-scoped metadata, not live backend connections.
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
for (const cache of cacheEntries) { for (const cache of cacheEntries) {
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`; const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
seenCacheKeys.add(compositeKey); seenCacheKeys.add(compositeKey);
backends.push({ backends.push({
id: `cache:${compositeKey}`,
backend: key, backend: key,
domain: cache.domain ?? null, domain: cache.domain ?? null,
protocol: cache.protocol ?? bm.protocol, protocol: cache.protocol ?? bm.protocol,
activeConnections: bm.activeConnections, activeConnections: 0,
totalConnections: bm.totalConnections, totalConnections: 0,
connectErrors: bm.connectErrors, connectErrors: 0,
handshakeErrors: bm.handshakeErrors, handshakeErrors: 0,
requestErrors: bm.requestErrors, requestErrors: 0,
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10, avgConnectTimeMs: 0,
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000, poolHitRate: 0,
h2Failures: bm.h2Failures, h2Failures: 0,
h2Suppressed: cache.h2Suppressed, h2Suppressed: cache.h2Suppressed,
h3Suppressed: cache.h3Suppressed, h3Suppressed: cache.h3Suppressed,
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs, h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
@@ -678,6 +682,7 @@ export class MetricsManager {
const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`; const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
if (!seenCacheKeys.has(compositeKey)) { if (!seenCacheKeys.has(compositeKey)) {
backends.push({ backends.push({
id: `cache:${compositeKey}`,
backend: `${entry.host}:${entry.port}`, backend: `${entry.host}:${entry.port}`,
domain: entry.domain, domain: entry.domain,
protocol: entry.protocol, protocol: entry.protocol,
@@ -750,6 +755,9 @@ export class MetricsManager {
// Resolve wildcards using domains seen in request metrics // Resolve wildcards using domains seen in request metrics
const allKnownDomains = new Set<string>(domainRequestTotals.keys()); const allKnownDomains = new Set<string>(domainRequestTotals.keys());
for (const domain of domainRequestRates.keys()) {
allKnownDomains.add(domain);
}
for (const entry of protocolCache) { for (const entry of protocolCache) {
if (entry.domain) allKnownDomains.add(entry.domain); 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 const hasLiveDomainRates = domainRequestRates.size > 0;
// so we can distribute throughput/connections proportionally 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<string, number>(); const routeTotalRequests = new Map<string, number>();
for (const [domain, routeKeys] of domainToRoutes) { for (const [domain, routeKeys] of domainToRoutes) {
const reqs = domainRequestTotals.get(domain) || 0; const reqs = getDomainWeight(domain);
for (const routeKey of routeKeys) { for (const routeKey of routeKeys) {
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs); routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
} }
@@ -792,10 +809,13 @@ export class MetricsManager {
bytesOutPerSec: number; bytesOutPerSec: number;
routeCount: number; routeCount: number;
requestCount: number; requestCount: number;
requestsPerSecond: number;
requestsLastMinute: number;
}>(); }>();
for (const [domain, routeKeys] of domainToRoutes) { 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 totalConns = 0;
let totalIn = 0; let totalIn = 0;
let totalOut = 0; let totalOut = 0;
@@ -816,7 +836,9 @@ export class MetricsManager {
bytesInPerSec: totalIn, bytesInPerSec: totalIn,
bytesOutPerSec: totalOut, bytesOutPerSec: totalOut,
routeCount: routeKeys.length, 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, activeConnections: data.activeConnections,
routeCount: data.routeCount, routeCount: data.routeCount,
requestCount: data.requestCount, 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 { return {
connectionsByIP, connectionsByIP,
+11 -5
View File
@@ -50,19 +50,21 @@ export class SecurityHandler {
localAddress: conn.destination.ip, localAddress: conn.destination.ip,
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 === 'active' ? 'connected' : conn.status as any,
bytesReceived: (conn as any)._throughputIn || 0, bytesReceived: (conn as any)._throughputIn || 0,
bytesSent: (conn as any)._throughputOut || 0, bytesSent: (conn as any)._throughputOut || 0,
connectionCount: conn.bytesTransferred || 1,
})); }));
const totalConnections = connectionInfos.reduce((sum, conn) => sum + (conn.connectionCount || 1), 0);
const summary = { const summary = {
total: connectionInfos.length, total: totalConnections,
byProtocol: connectionInfos.reduce((acc, conn) => { 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; return acc;
}, {} as { [protocol: string]: number }), }, {} as { [protocol: string]: number }),
byState: connectionInfos.reduce((acc, conn) => { 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; return acc;
}, {} as { [state: string]: number }), }, {} as { [state: string]: number }),
}; };
@@ -104,6 +106,8 @@ export class SecurityHandler {
requestsPerSecond: networkStats.requestsPerSecond || 0, requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0, requestsTotal: networkStats.requestsTotal || 0,
backends: networkStats.backends || [], backends: networkStats.backends || [],
frontendProtocols: networkStats.frontendProtocols || null,
backendProtocols: networkStats.backendProtocols || null,
}; };
} }
@@ -120,6 +124,8 @@ export class SecurityHandler {
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [], backends: [],
frontendProtocols: null,
backendProtocols: null,
}; };
} }
) )
@@ -335,4 +341,4 @@ export class SecurityHandler {
limits: [], limits: [],
}; };
} }
} }
+1
View File
@@ -302,6 +302,7 @@ export class StatsHandler {
startTime: 0, startTime: 0,
bytesIn: tp?.in || 0, bytesIn: tp?.in || 0,
bytesOut: tp?.out || 0, bytesOut: tp?.out || 0,
connectionCount: count,
}); });
} }
+11 -1
View File
@@ -119,6 +119,8 @@ export interface IConnectionInfo {
state: 'connecting' | 'connected' | 'authenticated' | 'transmitting' | 'closing'; state: 'connecting' | 'connected' | 'authenticated' | 'transmitting' | 'closing';
bytesReceived: number; bytesReceived: number;
bytesSent: number; bytesSent: number;
/** Present when the row is an aggregate, e.g. one row per remote IP. */
connectionCount?: number;
} }
export interface IQueueStatus { export interface IQueueStatus {
@@ -149,7 +151,12 @@ export interface IDomainActivity {
bytesOutPerSecond: number; bytesOutPerSecond: number;
activeConnections: number; activeConnections: number;
routeCount: number; routeCount: number;
/** Lifetime request count when available from SmartProxy. */
requestCount: number; 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 { export interface INetworkMetrics {
@@ -208,9 +215,12 @@ export interface IConnectionDetails {
startTime: number; startTime: number;
bytesIn: number; bytesIn: number;
bytesOut: number; bytesOut: number;
/** Present when the row is an aggregate, e.g. one row per remote IP. */
connectionCount?: number;
} }
export interface IBackendInfo { export interface IBackendInfo {
id?: string;
backend: string; backend: string;
domain: string | null; domain: string | null;
protocol: string; protocol: string;
@@ -250,4 +260,4 @@ export interface IVpnStats {
registeredClients: number; registeredClients: number;
connectedClients: number; connectedClients: number;
wgListenPort: number; wgListenPort: number;
} }
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.20.2', version: '13.21.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }
+23 -18
View File
@@ -512,15 +512,6 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
if (!context.identity) return currentState; if (!context.identity) return currentState;
try { 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 // Get network stats for throughput and IP data
const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetNetworkStats interfaces.requests.IReq_GetNetworkStats
@@ -533,22 +524,35 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
// Use the connections data for the connection list // Use the connections data for the connection list
// and network stats for throughput and IP analytics // and network stats for throughput and IP analytics
const connectionsByIP: { [ip: string]: number } = {}; const connectionsByIP: { [ip: string]: number } = {};
const throughputByIP = new Map<string, { in: number; out: number }>();
for (const item of networkStatsResponse.throughputByIP || []) {
throughputByIP.set(item.ip, { in: item.in, out: item.out });
}
// Build connectionsByIP from network stats if available // Build connectionsByIP from network stats if available
if (networkStatsResponse.connectionsByIP && Array.isArray(networkStatsResponse.connectionsByIP)) { if (networkStatsResponse.connectionsByIP && Array.isArray(networkStatsResponse.connectionsByIP)) {
networkStatsResponse.connectionsByIP.forEach((item: { ip: string; count: number }) => { networkStatsResponse.connectionsByIP.forEach((item: { ip: string; count: number }) => {
connectionsByIP[item.ip] = item.count; 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 { return {
connections: connectionsResponse.connections, connections,
connectionsByIP, connectionsByIP,
throughputRate: networkStatsResponse.throughputRate || { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: networkStatsResponse.throughputRate || { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
totalBytes: networkStatsResponse.totalDataTransferred totalBytes: networkStatsResponse.totalDataTransferred
@@ -2589,7 +2593,7 @@ async function dispatchCombinedRefreshActionInner() {
email: true, email: true,
dns: true, dns: true,
security: true, security: true,
network: currentView === 'network', // Only fetch network if on network view network: currentView === 'network' && currentSubview === 'activity',
radius: true, radius: true,
vpn: true, vpn: true,
}, },
@@ -2617,7 +2621,7 @@ async function dispatchCombinedRefreshActionInner() {
// Build connectionsByIP from connectionDetails (now populated with real per-IP data) // 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) + (conn.connectionCount || 1);
}); });
// Build connections from connectionDetails (real per-IP aggregates) // Build connections from connectionDetails (real per-IP aggregates)
@@ -2630,6 +2634,7 @@ async function dispatchCombinedRefreshActionInner() {
state: conn.state as any, state: conn.state as any,
bytesReceived: conn.bytesIn, bytesReceived: conn.bytesIn,
bytesSent: conn.bytesOut, bytesSent: conn.bytesOut,
connectionCount: conn.connectionCount,
})); }));
networkStatePart.setState({ networkStatePart.setState({
@@ -79,7 +79,6 @@ export class OpsViewNetworkActivity extends DeesElement {
// Subscribe and track unsubscribe functions // Subscribe and track unsubscribe functions
const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => { const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => {
this.statsState = state; this.statsState = state;
this.updateNetworkData();
}); });
this.rxSubscriptions.push(statsUnsubscribe); this.rxSubscriptions.push(statsUnsubscribe);
@@ -560,6 +559,8 @@ export class OpsViewNetworkActivity extends DeesElement {
'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond), 'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond),
'Transferred / min': this.formatBytes(totalBytesPerMin), 'Transferred / min': this.formatBytes(totalBytesPerMin),
'Connections': item.activeConnections, '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', 'Requests': item.requestCount?.toLocaleString() ?? '0',
'Routes': item.routeCount, 'Routes': item.routeCount,
}; };
@@ -583,7 +584,7 @@ export class OpsViewNetworkActivity extends DeesElement {
return html` return html`
<dees-table <dees-table
.data=${backends} .data=${backends}
.rowKey=${'backend'} .rowKey=${'id'}
.highlightUpdates=${'flash'} .highlightUpdates=${'flash'}
.displayFunction=${(item: interfaces.data.IBackendInfo) => { .displayFunction=${(item: interfaces.data.IBackendInfo) => {
const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors; const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors;
@@ -707,6 +708,9 @@ export class OpsViewNetworkActivity extends DeesElement {
} }
const throughput = this.calculateThroughput(); const throughput = this.calculateThroughput();
if (this.networkState.lastUpdated && now - this.networkState.lastUpdated > 3000) {
return;
}
// Convert to Mbps (bytes * 8 / 1,000,000) // Convert to Mbps (bytes * 8 / 1,000,000)
const throughputInMbps = (throughput.in * 8) / 1000000; const throughputInMbps = (throughput.in * 8) / 1000000;