Compare commits

...

10 Commits

Author SHA1 Message Date
e193b3a8eb v13.17.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:17:46 +00:00
1bbf31605c fix(monitoring): exclude unconfigured routes from domain activity aggregation 2026-04-13 19:17:46 +00:00
f2cfa923a0 v13.17.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:15:46 +00:00
cdc77305e5 fix(monitoring): stop allocating route metrics to domains when no request data exists 2026-04-13 19:15:46 +00:00
835537f789 v13.17.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:12:56 +00:00
754b223f62 feat(monitoring,network-ui,routes): add request-based domain activity metrics and split routes into user and system views 2026-04-13 19:12:56 +00:00
0a39d50d20 v13.16.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 18:51:41 +00:00
de7b9f7ec5 fix(deps): bump @push.rocks/smartproxy to ^27.6.0 2026-04-13 18:51:41 +00:00
bd959464c7 v13.16.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 18:08:36 +00:00
36b629676f fix(migrations): use exact smartdata collection names in route unification migration 2026-04-13 18:08:36 +00:00
11 changed files with 135 additions and 85 deletions

17
AGENTS.md Normal file
View File

@@ -0,0 +1,17 @@
# Agent Instructions for dcrouter
## Database & Migrations
### Collection Names
smartdata uses the **exact class name** as the MongoDB collection name. No lowercasing.
- `StoredRouteDoc` → collection `StoredRouteDoc`
- `TargetProfileDoc` → collection `TargetProfileDoc`
- `RouteDoc` → collection `RouteDoc`
When writing migrations in `ts_migrations/index.ts`, use the exact class name casing in `ctx.mongo!.collection('ClassName')` and `db.listCollections({ name: 'ClassName' })`.
### Migration Rules
- All DB schema migrations go EXCLUSIVELY in `ts_migrations/index.ts` as smartmigration steps.
- NEVER put migration logic in application code (services, managers, startup hooks).
- Migration step `.to()` version must match the release version so smartmigration can plan the step.
- Steps must be idempotent — smartmigration may re-run them in skip-forward resume mode.

View File

@@ -1,5 +1,36 @@
# Changelog
## 2026-04-13 - 13.17.2 - fix(monitoring)
exclude unconfigured routes from domain activity aggregation
- Removes fallback aggregation that reported routes without domain configuration as synthetic domain entries based on route names
- Keeps domain activity focused on configured domain mappings when splitting connection and throughput metrics
## 2026-04-13 - 13.17.1 - fix(monitoring)
stop allocating route metrics to domains when no request data exists
- Removes the equal-split fallback for shared routes in MetricsManager.
- Sets the proportional share to zero when a route has no recorded requests, avoiding inflated per-domain connection and throughput totals.
## 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)
bump @push.rocks/smartproxy to ^27.6.0
- updates @push.rocks/smartproxy from ^27.5.0 to ^27.6.0 in package.json
## 2026-04-13 - 13.16.1 - fix(migrations)
use exact smartdata collection names in route unification migration
- Update the 13.16.0 migration to rename StoredRouteDoc to RouteDoc using case-sensitive collection names
- Apply the origin backfill against the RouteDoc collection and drop RouteOverrideDoc with matching class-name casing
- Clarify migration description and comments to reflect smartdata's exact class-name collection mapping
## 2026-04-13 - 13.16.0 - feat(routes)
unify route storage and management across config, email, dns, and API origins

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.16.0",
"version": "13.17.2",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -54,7 +54,7 @@
"@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.5.0",
"@push.rocks/smartproxy": "^27.6.0",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",

10
pnpm-lock.yaml generated
View File

@@ -81,8 +81,8 @@ importers:
specifier: ^4.2.3
version: 4.2.3
'@push.rocks/smartproxy':
specifier: ^27.5.0
version: 27.5.0
specifier: ^27.6.0
version: 27.6.0
'@push.rocks/smartradius':
specifier: ^1.1.1
version: 1.1.1
@@ -1287,8 +1287,8 @@ packages:
'@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@27.5.0':
resolution: {integrity: sha512-QIXrVQtAoqBCv+9ScLOdGcizN55svJuGCfMDsDaBVtwS3Tva30IxuEL3usNTHABveuI8slaWzSxTabmTULDOwA==}
'@push.rocks/smartproxy@27.6.0':
resolution: {integrity: sha512-1mPzabUKhlC0EdeI7Hjee/aiptTsOLftbq8oWBTlIg9JhCQwkIs5UNGTJV/VvlEflJKnay8TbzLzlr95gUr/1w==}
'@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -6521,7 +6521,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@27.5.0':
'@push.rocks/smartproxy@27.6.0':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2

View File

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

View File

@@ -560,7 +560,7 @@ export class MetricsManager {
requestsPerSecond: 0,
requestsTotal: 0,
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)
.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 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[]>();
if (this.dcRouter.smartProxy) {
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)
const activeDomains = new Set<string>();
const domainToBackend = new Map<string, string>(); // domain → host:port
// Resolve wildcards using domains seen in request metrics
const allKnownDomains = new Set<string>(domainRequestTotals.keys());
for (const entry of protocolCache) {
if (entry.domain) {
activeDomains.add(entry.domain);
domainToBackend.set(entry.domain, `${entry.host}:${entry.port}`);
}
if (entry.domain) allKnownDomains.add(entry.domain);
}
// Build reverse map: domain → route name(s) that handle it
// For concrete domains: direct lookup from route config
// For wildcard patterns: match active domains from protocol cache
// Build reverse map: concrete domain → route name(s)
const domainToRoutes = new Map<string, string[]>();
for (const [routeName, domains] of routeDomains) {
for (const pattern of domains) {
if (pattern.includes('*')) {
// Wildcard pattern — match against active domains from protocol cache
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
for (const activeDomain of activeDomains) {
if (regex.test(activeDomain)) {
const existing = domainToRoutes.get(activeDomain);
for (const knownDomain of allKnownDomains) {
if (regex.test(knownDomain)) {
const existing = domainToRoutes.get(knownDomain);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(activeDomain, [routeName]); }
else { domainToRoutes.set(knownDomain, [routeName]); }
}
}
} else {
// Concrete domain
const existing = domainToRoutes.get(pattern);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(pattern, [routeName]); }
@@ -773,37 +774,40 @@ export class MetricsManager {
}
}
// Aggregate metrics per domain
// For each domain, sum metrics from all routes that serve it,
// divided by the number of domains each route serves
// For each route, compute the total request count across all its resolved domains
// so we can distribute throughput/connections proportionally
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, {
activeConnections: number;
bytesInPerSec: number;
bytesOutPerSec: number;
routeCount: number;
requestCount: number;
}>();
// Track which routes are accounted for
const accountedRoutes = new Set<string>();
for (const [domain, routeNames] of domainToRoutes) {
const domainReqs = domainRequestTotals.get(domain) || 0;
let totalConns = 0;
let totalIn = 0;
let totalOut = 0;
for (const routeName of routeNames) {
accountedRoutes.add(routeName);
const conns = connectionsByRoute.get(routeName) || 0;
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
// Count how many resolved domains share this route
let domainsInRoute = 0;
for (const [, routes] of domainToRoutes) {
if (routes.includes(routeName)) domainsInRoute++;
}
const share = Math.max(domainsInRoute, 1);
totalConns += conns / share;
totalIn += tp.in / share;
totalOut += tp.out / share;
const routeTotal = routeTotalRequests.get(routeName) || 0;
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
totalConns += conns * share;
totalIn += tp.in * share;
totalOut += tp.out * share;
}
domainAgg.set(domain, {
@@ -811,31 +815,10 @@ export class MetricsManager {
bytesInPerSec: totalIn,
bytesOutPerSec: totalOut,
routeCount: routeNames.length,
requestCount: domainReqs,
});
}
// Include routes with no domain config (fallback: use route name)
for (const [routeName, activeConns] of connectionsByRoute) {
if (accountedRoutes.has(routeName)) continue;
if (routeDomains.has(routeName)) continue; // has domains but no traffic matched
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
if (activeConns === 0 && tp.in === 0 && tp.out === 0) continue;
const existing = domainAgg.get(routeName);
if (existing) {
existing.activeConnections += activeConns;
existing.bytesInPerSec += tp.in;
existing.bytesOutPerSec += tp.out;
existing.routeCount++;
} else {
domainAgg.set(routeName, {
activeConnections: activeConns,
bytesInPerSec: tp.in,
bytesOutPerSec: tp.out,
routeCount: 1,
});
}
}
const domainActivity = Array.from(domainAgg.entries())
.map(([domain, data]) => ({
domain,
@@ -843,6 +826,7 @@ export class MetricsManager {
bytesOutPerSecond: data.bytesOutPerSec,
activeConnections: data.activeConnections,
routeCount: data.routeCount,
requestCount: data.requestCount,
}))
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));

View File

@@ -149,6 +149,7 @@ export interface IDomainActivity {
bytesOutPerSecond: number;
activeConnections: number;
routeCount: number;
requestCount: number;
}
export interface INetworkMetrics {

View File

@@ -95,30 +95,30 @@ export async function createMigrationRunner(
})
.step('unify-routes-rename-collection')
.from('13.8.2').to('13.16.0')
.description('Rename storedroutedoc → routedoc, add origin field, drop routeoverridedoc')
.description('Rename StoredRouteDoc → RouteDoc, add origin field, drop RouteOverrideDoc')
.up(async (ctx) => {
const db = ctx.mongo!;
// 1. Rename storedroutedoc → routedoc
const collections = await db.listCollections({ name: 'storedroutedoc' }).toArray();
// 1. Rename StoredRouteDoc → RouteDoc (smartdata uses exact class names)
const collections = await db.listCollections({ name: 'StoredRouteDoc' }).toArray();
if (collections.length > 0) {
await db.renameCollection('storedroutedoc', 'routedoc');
ctx.log.log('info', 'Renamed storedroutedoc → routedoc');
await db.renameCollection('StoredRouteDoc', 'RouteDoc');
ctx.log.log('info', 'Renamed StoredRouteDoc → RouteDoc');
}
// 2. Set origin='api' on all migrated docs (they were API-created)
const routeCol = db.collection('routedoc');
const routeCol = db.collection('RouteDoc');
const result = await routeCol.updateMany(
{ origin: { $exists: false } },
{ $set: { origin: 'api' } },
);
ctx.log.log('info', `Set origin='api' on ${result.modifiedCount} migrated route(s)`);
// 3. Drop routeoverridedoc collection
const overrideCollections = await db.listCollections({ name: 'routeoverridedoc' }).toArray();
// 3. Drop RouteOverrideDoc collection
const overrideCollections = await db.listCollections({ name: 'RouteOverrideDoc' }).toArray();
if (overrideCollections.length > 0) {
await db.collection('routeoverridedoc').drop();
ctx.log.log('info', 'Dropped routeoverridedoc collection');
await db.collection('RouteOverrideDoc').drop();
ctx.log.log('info', 'Dropped RouteOverrideDoc collection');
}
});

View File

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

View File

@@ -560,11 +560,12 @@ export class OpsViewNetworkActivity extends DeesElement {
'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond),
'Transferred / min': this.formatBytes(totalBytesPerMin),
'Connections': item.activeConnections,
'Requests': item.requestCount?.toLocaleString() ?? '0',
'Routes': item.routeCount,
};
}}
heading1="Domain Activity"
heading2="Per-domain network activity aggregated from route metrics"
heading2="Per-domain network activity from request-level metrics"
searchable
.showColumnFilters=${true}
.pagination=${false}

View File

@@ -49,6 +49,8 @@ function setupTlsVisibility(formEl: any) {
@customElement('ops-view-routes')
export class OpsViewRoutes extends DeesElement {
@state() accessor routeFilter: 'User Routes' | 'System Routes' = 'User Routes';
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
@@ -156,20 +158,20 @@ export class OpsViewRoutes extends DeesElement {
},
{
id: 'configRoutes',
title: 'From Config',
title: 'System Routes',
type: 'number',
value: configCount,
icon: 'lucide:settings',
description: 'Seeded from config/email/DNS',
description: 'From config, email, and DNS',
color: '#8b5cf6',
},
{
id: 'apiRoutes',
title: 'API Created',
title: 'User Routes',
type: 'number',
value: apiCount,
icon: 'lucide:code',
description: 'Routes added via API',
description: 'Created via API',
color: '#0ea5e9',
},
{
@@ -183,8 +185,14 @@ export class OpsViewRoutes extends DeesElement {
},
];
// Map merged routes to sz-route-list-view format
const szRoutes = mergedRoutes.map((mr) => {
// Filter routes based on selected tab
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 || [])];
tags.push(mr.origin);
if (!mr.enabled) tags.push('disabled');
@@ -218,6 +226,13 @@ export class OpsViewRoutes extends DeesElement {
]}
></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
? html`
<div class="warnings-bar">
@@ -237,6 +252,7 @@ export class OpsViewRoutes extends DeesElement {
? html`
<sz-route-list-view
.routes=${szRoutes}
.showActionsFilter=${isUserRoutes ? () => true : () => false}
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
@@ -244,8 +260,8 @@ export class OpsViewRoutes extends DeesElement {
`
: html`
<div class="empty-state">
<p>No routes configured</p>
<p>Add a route to get started.</p>
<p>No ${isUserRoutes ? 'user' : 'system'} routes</p>
<p>${isUserRoutes ? 'Add a route to get started.' : 'System routes are generated from config, email, and DNS settings.'}</p>
</div>
`}
</div>