Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e193b3a8eb | |||
| 1bbf31605c | |||
| f2cfa923a0 | |||
| cdc77305e5 | |||
| 835537f789 | |||
| 754b223f62 | |||
| 0a39d50d20 | |||
| de7b9f7ec5 | |||
| bd959464c7 | |||
| 36b629676f |
17
AGENTS.md
Normal file
17
AGENTS.md
Normal 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.
|
||||
31
changelog.md
31
changelog.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ export interface IDomainActivity {
|
||||
bytesOutPerSecond: number;
|
||||
activeConnections: number;
|
||||
routeCount: number;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
export interface INetworkMetrics {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user