feat(monitoring,network-ui,routes): add request-based domain activity metrics and split routes into user and system views
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.17.0 - feat(monitoring,network-ui,routes)
|
||||||
|
add request-based domain activity metrics and split routes into user and system views
|
||||||
|
|
||||||
|
- Domain activity now includes per-domain request counts and distributes route throughput and connections using request-level metrics instead of equal route sharing.
|
||||||
|
- Network activity UI displays request counts and updates the domain activity description to reflect request-level aggregation.
|
||||||
|
- Routes UI adds a toggle to filter between user-created and system-generated routes, updates summary card labels, and adjusts empty states accordingly.
|
||||||
|
|
||||||
## 2026-04-13 - 13.16.2 - fix(deps)
|
## 2026-04-13 - 13.16.2 - fix(deps)
|
||||||
bump @push.rocks/smartproxy to ^27.6.0
|
bump @push.rocks/smartproxy to ^27.6.0
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.16.2',
|
version: '13.17.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ 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 }>,
|
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number }>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,11 +720,20 @@ export class MetricsManager {
|
|||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
||||||
|
|
||||||
// Build domain activity from per-route metrics
|
// Build domain activity using per-IP domain request counts from Rust engine
|
||||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||||
const throughputByRoute = proxyMetrics.throughput.byRoute();
|
const throughputByRoute = proxyMetrics.throughput.byRoute();
|
||||||
|
|
||||||
// Map route name → ALL its domains (not just the first one)
|
// Aggregate per-IP domain request counts into per-domain totals
|
||||||
|
const domainRequestTotals = new Map<string, number>();
|
||||||
|
const domainRequestsByIP = proxyMetrics.connections.domainRequestsByIP();
|
||||||
|
for (const [, domainMap] of domainRequestsByIP) {
|
||||||
|
for (const [domain, count] of domainMap) {
|
||||||
|
domainRequestTotals.set(domain, (domainRequestTotals.get(domain) || 0) + count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map route name → 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()) {
|
||||||
@@ -738,34 +747,26 @@ export class MetricsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use protocol cache to discover actual active domains (resolves wildcards)
|
// Resolve wildcards using domains seen in request metrics
|
||||||
const activeDomains = new Set<string>();
|
const allKnownDomains = new Set<string>(domainRequestTotals.keys());
|
||||||
const domainToBackend = new Map<string, string>(); // domain → host:port
|
|
||||||
for (const entry of protocolCache) {
|
for (const entry of protocolCache) {
|
||||||
if (entry.domain) {
|
if (entry.domain) allKnownDomains.add(entry.domain);
|
||||||
activeDomains.add(entry.domain);
|
|
||||||
domainToBackend.set(entry.domain, `${entry.host}:${entry.port}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build reverse map: domain → route name(s) that handle it
|
// Build reverse map: concrete domain → route name(s)
|
||||||
// For concrete domains: direct lookup from route config
|
|
||||||
// For wildcard patterns: match active domains from protocol cache
|
|
||||||
const domainToRoutes = new Map<string, string[]>();
|
const domainToRoutes = new Map<string, string[]>();
|
||||||
for (const [routeName, domains] of routeDomains) {
|
for (const [routeName, domains] of routeDomains) {
|
||||||
for (const pattern of domains) {
|
for (const pattern of domains) {
|
||||||
if (pattern.includes('*')) {
|
if (pattern.includes('*')) {
|
||||||
// Wildcard pattern — match against active domains from protocol cache
|
|
||||||
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
||||||
for (const activeDomain of activeDomains) {
|
for (const knownDomain of allKnownDomains) {
|
||||||
if (regex.test(activeDomain)) {
|
if (regex.test(knownDomain)) {
|
||||||
const existing = domainToRoutes.get(activeDomain);
|
const existing = domainToRoutes.get(knownDomain);
|
||||||
if (existing) { existing.push(routeName); }
|
if (existing) { existing.push(routeName); }
|
||||||
else { domainToRoutes.set(activeDomain, [routeName]); }
|
else { domainToRoutes.set(knownDomain, [routeName]); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Concrete domain
|
|
||||||
const existing = domainToRoutes.get(pattern);
|
const existing = domainToRoutes.get(pattern);
|
||||||
if (existing) { existing.push(routeName); }
|
if (existing) { existing.push(routeName); }
|
||||||
else { domainToRoutes.set(pattern, [routeName]); }
|
else { domainToRoutes.set(pattern, [routeName]); }
|
||||||
@@ -773,20 +774,28 @@ export class MetricsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate metrics per domain
|
// For each route, compute the total request count across all its resolved domains
|
||||||
// For each domain, sum metrics from all routes that serve it,
|
// so we can distribute throughput/connections proportionally
|
||||||
// divided by the number of domains each route serves
|
const routeTotalRequests = new Map<string, number>();
|
||||||
|
for (const [domain, routeNames] of domainToRoutes) {
|
||||||
|
const reqs = domainRequestTotals.get(domain) || 0;
|
||||||
|
for (const routeName of routeNames) {
|
||||||
|
routeTotalRequests.set(routeName, (routeTotalRequests.get(routeName) || 0) + reqs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate metrics per domain using request-count-proportional splitting
|
||||||
const domainAgg = new Map<string, {
|
const domainAgg = new Map<string, {
|
||||||
activeConnections: number;
|
activeConnections: number;
|
||||||
bytesInPerSec: number;
|
bytesInPerSec: number;
|
||||||
bytesOutPerSec: number;
|
bytesOutPerSec: number;
|
||||||
routeCount: number;
|
routeCount: number;
|
||||||
|
requestCount: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Track which routes are accounted for
|
|
||||||
const accountedRoutes = new Set<string>();
|
const accountedRoutes = new Set<string>();
|
||||||
|
|
||||||
for (const [domain, routeNames] of domainToRoutes) {
|
for (const [domain, routeNames] of domainToRoutes) {
|
||||||
|
const domainReqs = domainRequestTotals.get(domain) || 0;
|
||||||
let totalConns = 0;
|
let totalConns = 0;
|
||||||
let totalIn = 0;
|
let totalIn = 0;
|
||||||
let totalOut = 0;
|
let totalOut = 0;
|
||||||
@@ -795,15 +804,25 @@ export class MetricsManager {
|
|||||||
accountedRoutes.add(routeName);
|
accountedRoutes.add(routeName);
|
||||||
const conns = connectionsByRoute.get(routeName) || 0;
|
const conns = connectionsByRoute.get(routeName) || 0;
|
||||||
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
|
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
|
||||||
|
const routeTotal = routeTotalRequests.get(routeName) || 0;
|
||||||
|
|
||||||
|
// Proportional share based on actual request counts
|
||||||
|
// Fall back to equal split only when no request data exists
|
||||||
|
let share: number;
|
||||||
|
if (routeTotal > 0 && domainReqs > 0) {
|
||||||
|
share = domainReqs / routeTotal;
|
||||||
|
} else {
|
||||||
// Count how many resolved domains share this route
|
// Count how many resolved domains share this route
|
||||||
let domainsInRoute = 0;
|
let domainsInRoute = 0;
|
||||||
for (const [, routes] of domainToRoutes) {
|
for (const [, routes] of domainToRoutes) {
|
||||||
if (routes.includes(routeName)) domainsInRoute++;
|
if (routes.includes(routeName)) domainsInRoute++;
|
||||||
}
|
}
|
||||||
const share = Math.max(domainsInRoute, 1);
|
share = 1 / Math.max(domainsInRoute, 1);
|
||||||
totalConns += conns / share;
|
}
|
||||||
totalIn += tp.in / share;
|
|
||||||
totalOut += tp.out / share;
|
totalConns += conns * share;
|
||||||
|
totalIn += tp.in * share;
|
||||||
|
totalOut += tp.out * share;
|
||||||
}
|
}
|
||||||
|
|
||||||
domainAgg.set(domain, {
|
domainAgg.set(domain, {
|
||||||
@@ -811,13 +830,14 @@ export class MetricsManager {
|
|||||||
bytesInPerSec: totalIn,
|
bytesInPerSec: totalIn,
|
||||||
bytesOutPerSec: totalOut,
|
bytesOutPerSec: totalOut,
|
||||||
routeCount: routeNames.length,
|
routeCount: routeNames.length,
|
||||||
|
requestCount: domainReqs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include routes with no domain config (fallback: use route name)
|
// Include routes with no domain config (fallback: use route name)
|
||||||
for (const [routeName, activeConns] of connectionsByRoute) {
|
for (const [routeName, activeConns] of connectionsByRoute) {
|
||||||
if (accountedRoutes.has(routeName)) continue;
|
if (accountedRoutes.has(routeName)) continue;
|
||||||
if (routeDomains.has(routeName)) continue; // has domains but no traffic matched
|
if (routeDomains.has(routeName)) continue;
|
||||||
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
|
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
|
||||||
if (activeConns === 0 && tp.in === 0 && tp.out === 0) continue;
|
if (activeConns === 0 && tp.in === 0 && tp.out === 0) continue;
|
||||||
const existing = domainAgg.get(routeName);
|
const existing = domainAgg.get(routeName);
|
||||||
@@ -832,6 +852,7 @@ export class MetricsManager {
|
|||||||
bytesInPerSec: tp.in,
|
bytesInPerSec: tp.in,
|
||||||
bytesOutPerSec: tp.out,
|
bytesOutPerSec: tp.out,
|
||||||
routeCount: 1,
|
routeCount: 1,
|
||||||
|
requestCount: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -843,6 +864,7 @@ export class MetricsManager {
|
|||||||
bytesOutPerSecond: data.bytesOutPerSec,
|
bytesOutPerSecond: data.bytesOutPerSec,
|
||||||
activeConnections: data.activeConnections,
|
activeConnections: data.activeConnections,
|
||||||
routeCount: data.routeCount,
|
routeCount: data.routeCount,
|
||||||
|
requestCount: data.requestCount,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
|
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ export interface IDomainActivity {
|
|||||||
bytesOutPerSecond: number;
|
bytesOutPerSecond: number;
|
||||||
activeConnections: number;
|
activeConnections: number;
|
||||||
routeCount: number;
|
routeCount: number;
|
||||||
|
requestCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface INetworkMetrics {
|
export interface INetworkMetrics {
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.16.2',
|
version: '13.17.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,11 +560,12 @@ 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,
|
||||||
|
'Requests': item.requestCount?.toLocaleString() ?? '0',
|
||||||
'Routes': item.routeCount,
|
'Routes': item.routeCount,
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
heading1="Domain Activity"
|
heading1="Domain Activity"
|
||||||
heading2="Per-domain network activity aggregated from route metrics"
|
heading2="Per-domain network activity from request-level metrics"
|
||||||
searchable
|
searchable
|
||||||
.showColumnFilters=${true}
|
.showColumnFilters=${true}
|
||||||
.pagination=${false}
|
.pagination=${false}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ function setupTlsVisibility(formEl: any) {
|
|||||||
|
|
||||||
@customElement('ops-view-routes')
|
@customElement('ops-view-routes')
|
||||||
export class OpsViewRoutes extends DeesElement {
|
export class OpsViewRoutes extends DeesElement {
|
||||||
|
@state() accessor routeFilter: 'User Routes' | 'System Routes' = 'User Routes';
|
||||||
|
|
||||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||||
mergedRoutes: [],
|
mergedRoutes: [],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
@@ -156,20 +158,20 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'configRoutes',
|
id: 'configRoutes',
|
||||||
title: 'From Config',
|
title: 'System Routes',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
value: configCount,
|
value: configCount,
|
||||||
icon: 'lucide:settings',
|
icon: 'lucide:settings',
|
||||||
description: 'Seeded from config/email/DNS',
|
description: 'From config, email, and DNS',
|
||||||
color: '#8b5cf6',
|
color: '#8b5cf6',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'apiRoutes',
|
id: 'apiRoutes',
|
||||||
title: 'API Created',
|
title: 'User Routes',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
value: apiCount,
|
value: apiCount,
|
||||||
icon: 'lucide:code',
|
icon: 'lucide:code',
|
||||||
description: 'Routes added via API',
|
description: 'Created via API',
|
||||||
color: '#0ea5e9',
|
color: '#0ea5e9',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -183,8 +185,14 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Map merged routes to sz-route-list-view format
|
// Filter routes based on selected tab
|
||||||
const szRoutes = mergedRoutes.map((mr) => {
|
const isUserRoutes = this.routeFilter === 'User Routes';
|
||||||
|
const filteredRoutes = mergedRoutes.filter((mr) =>
|
||||||
|
isUserRoutes ? mr.origin === 'api' : mr.origin !== 'api'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map filtered routes to sz-route-list-view format
|
||||||
|
const szRoutes = filteredRoutes.map((mr) => {
|
||||||
const tags = [...(mr.route.tags || [])];
|
const tags = [...(mr.route.tags || [])];
|
||||||
tags.push(mr.origin);
|
tags.push(mr.origin);
|
||||||
if (!mr.enabled) tags.push('disabled');
|
if (!mr.enabled) tags.push('disabled');
|
||||||
@@ -218,6 +226,13 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
]}
|
]}
|
||||||
></dees-statsgrid>
|
></dees-statsgrid>
|
||||||
|
|
||||||
|
<dees-input-multitoggle
|
||||||
|
.type=${'single'}
|
||||||
|
.options=${['User Routes', 'System Routes']}
|
||||||
|
.selectedOption=${this.routeFilter}
|
||||||
|
@change=${(e: any) => { this.routeFilter = e.target.value || e.target.selectedOption; }}
|
||||||
|
></dees-input-multitoggle>
|
||||||
|
|
||||||
${warnings.length > 0
|
${warnings.length > 0
|
||||||
? html`
|
? html`
|
||||||
<div class="warnings-bar">
|
<div class="warnings-bar">
|
||||||
@@ -237,6 +252,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
? html`
|
? html`
|
||||||
<sz-route-list-view
|
<sz-route-list-view
|
||||||
.routes=${szRoutes}
|
.routes=${szRoutes}
|
||||||
|
.showActionsFilter=${isUserRoutes ? () => true : () => false}
|
||||||
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
|
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
|
||||||
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
|
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
|
||||||
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
|
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
|
||||||
@@ -244,8 +260,8 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>No routes configured</p>
|
<p>No ${isUserRoutes ? 'user' : 'system'} routes</p>
|
||||||
<p>Add a route to get started.</p>
|
<p>${isUserRoutes ? 'Add a route to get started.' : 'System routes are generated from config, email, and DNS settings.'}</p>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user