Compare commits

...

13 Commits

Author SHA1 Message Date
2d44528345 v5.5.0
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 14:27:59 +00:00
28a38252da feat(certs): persist ACME certificates in StorageManager, add storage-backed cert manager, default storage to filesystem, and improve certificate status reporting 2026-02-14 14:27:58 +00:00
dfb268bbfc v5.4.6
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 12:49:57 +00:00
6532c7ff22 fix(deps): bump @push.rocks/smartproxy dependency to ^25.2.2 2026-02-14 12:49:57 +00:00
d2c63cf170 v5.4.5
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 12:33:04 +00:00
09d66e4528 fix(dcrouter): bump patch for release pipeline consistency - no code changes 2026-02-14 12:33:04 +00:00
3078fa9d7b feat(dashboard): use SmartProxy server-side throughput history and per-IP bandwidth in network view 2026-02-14 12:31:44 +00:00
57fbb128e6 v5.4.4
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 11:26:58 +00:00
d73266eeb8 fix(deps): bump @push.rocks/smartproxy to ^25.2.0 2026-02-14 11:26:58 +00:00
2dbdf2d2b1 v5.4.3
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 09:25:59 +00:00
383e0adc23 fix(dependencies): bump @push.rocks/smartproxy to ^25.1.0 2026-02-14 09:25:59 +00:00
d7789f5a44 v5.4.2
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-13 23:16:25 +00:00
2638990667 fix(dcrouter): improve domain pattern matching to support routing-glob and wildcard patterns and use matching logic when resolving routes 2026-02-13 23:16:25 +00:00
14 changed files with 329 additions and 82 deletions

View File

@@ -1,5 +1,47 @@
# Changelog # Changelog
## 2026-02-14 - 5.5.0 - feat(certs)
persist ACME certificates in StorageManager, add storage-backed cert manager, default storage to filesystem, and improve certificate status reporting
- Add StorageBackedCertManager to persist SmartAcme certificates under /certs/ via StorageManager
- Default storage to filesystem path (dcrouterHomeDir/storage) when options.storage is not provided
- Wire SmartAcme to use StorageBackedCertManager and provide SmartProxy certStore handlers that load/save/remove certs under /proxy-certs/
- Ops server certificate handler reads persisted cert data to report expiry/issued dates and treats acme/provision-function routes with no cert data as provisioning
- Bump @push.rocks/smartproxy dependency to ^25.3.0
## 2026-02-14 - 5.4.6 - fix(deps)
bump @push.rocks/smartproxy dependency to ^25.2.2
- Updated dependency @push.rocks/smartproxy: ^25.2.0 → ^25.2.2
- Change is a dependency-only patch update, no source code modifications
- Current package version is 5.4.5; recommend a patch release
## 2026-02-14 - 5.4.5 - fix(dcrouter)
bump patch for release pipeline consistency - no code changes
- current version: 5.4.4 (from package.json)
- git diff: no changes detected
- recommend patch bump to trigger release artifacts if required
## 2026-02-14 - 5.4.4 - fix(deps)
bump @push.rocks/smartproxy to ^25.2.0
- Updated @push.rocks/smartproxy from ^25.1.0 to ^25.2.0 (patch, non-breaking).
- Current package version is 5.4.3; recommend a patch release to 5.4.4.
## 2026-02-14 - 5.4.3 - fix(dependencies)
bump @push.rocks/smartproxy to ^25.1.0
- Updated @push.rocks/smartproxy from ^25.0.0 to ^25.1.0 in package.json
## 2026-02-13 - 5.4.2 - fix(dcrouter)
improve domain pattern matching to support routing-glob and wildcard patterns and use matching logic when resolving routes
- Support routing-glob patterns beginning with '*' (e.g. *example.com) to match base domain, wildcard form, and subdomains
- Treat standard wildcard patterns ('*.example.com') as matching both the base domain (example.com) and its subdomains
- Use isDomainMatch when resolving routes instead of exact array includes to allow pattern matching
- Normalize domain and pattern to lowercase and simplify equality checks
## 2026-02-13 - 5.4.1 - fix(network,dcrouter) ## 2026-02-13 - 5.4.1 - fix(network,dcrouter)
Always register SmartProxy certificate event handlers and include total bytes + improved connection metrics in network stats/UI Always register SmartProxy certificate event handlers and include total bytes + improved connection metrics in network stats/UI

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "5.4.1", "version": "5.5.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": {
@@ -49,7 +49,7 @@
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.4.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": "^25.0.0", "@push.rocks/smartproxy": "^25.3.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",

10
pnpm-lock.yaml generated
View File

@@ -75,8 +75,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: ^25.0.0 specifier: ^25.3.0
version: 25.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7) version: 25.3.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)
'@push.rocks/smartradius': '@push.rocks/smartradius':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
@@ -1040,8 +1040,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@25.0.0': '@push.rocks/smartproxy@25.3.0':
resolution: {integrity: sha512-FuXIyKAlTdUUSFszzYjP/WAMb3Dq//gBdluADvjgAeQn1YplFonMo/afRU+qSI7WsPsB7X7vkFwLba5ASYdiUg==} resolution: {integrity: sha512-ie0jP6dCSZFvrdRmlo5NTufA6AJeQdGsgVQv6M9okQ4IXBkm3LVN+u6t9T2nHalnopMJXLb+qAuq0Y2T5mxIJg==}
'@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==}
@@ -6441,7 +6441,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {} '@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@25.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)': '@push.rocks/smartproxy@25.3.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)':
dependencies: dependencies:
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
'@push.rocks/smartacme': 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7) '@push.rocks/smartacme': 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)

View File

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

View File

@@ -13,6 +13,7 @@ import {
import { logger } from './logger.js'; import { logger } from './logger.js';
// Import storage manager // Import storage manager
import { StorageManager, type IStorageConfig } from './storage/index.js'; import { StorageManager, type IStorageConfig } from './storage/index.js';
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
// Import cache system // Import cache system
import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js'; import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js';
@@ -205,6 +206,13 @@ export class DcRouter {
...optionsArg ...optionsArg
}; };
// Default storage to filesystem if not configured
if (!this.options.storage) {
this.options.storage = {
fsPath: plugins.path.join(paths.dcrouterHomeDir, 'storage'),
};
}
// Initialize storage manager // Initialize storage manager
this.storageManager = new StorageManager(this.options.storage); this.storageManager = new StorageManager(this.options.storage);
} }
@@ -437,14 +445,33 @@ export class DcRouter {
const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = { const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
...this.options.smartProxyConfig, ...this.options.smartProxyConfig,
routes, routes,
acme: acmeConfig acme: acmeConfig,
certStore: {
loadAll: async () => {
const keys = await this.storageManager.list('/proxy-certs/');
const certs: Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }> = [];
for (const key of keys) {
const data = await this.storageManager.getJSON(key);
if (data) certs.push(data);
}
return certs;
},
save: async (domain: string, publicKey: string, privateKey: string, ca?: string) => {
await this.storageManager.setJSON(`/proxy-certs/${domain}`, {
domain, publicKey, privateKey, ca,
});
},
remove: async (domain: string) => {
await this.storageManager.delete(`/proxy-certs/${domain}`);
},
},
}; };
// If we have DNS challenge handlers, create SmartAcme and wire to certProvisionFunction // If we have DNS challenge handlers, create SmartAcme and wire to certProvisionFunction
if (challengeHandlers.length > 0) { if (challengeHandlers.length > 0) {
this.smartAcme = new plugins.smartacme.SmartAcme({ this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com', accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(), certManager: new StorageBackedCertManager(this.storageManager),
environment: 'production', environment: 'production',
challengeHandlers: challengeHandlers, challengeHandlers: challengeHandlers,
challengePriority: ['dns-01'], challengePriority: ['dns-01'],
@@ -674,24 +701,25 @@ export class DcRouter {
* @returns Whether the domain matches the pattern * @returns Whether the domain matches the pattern
*/ */
private isDomainMatch(domain: string, pattern: string): boolean { private isDomainMatch(domain: string, pattern: string): boolean {
// Normalize inputs
domain = domain.toLowerCase(); domain = domain.toLowerCase();
pattern = pattern.toLowerCase(); pattern = pattern.toLowerCase();
// Check for exact match if (domain === pattern) return true;
if (domain === pattern) {
return true; // Routing-glob: *example.com matches example.com, sub.example.com, *.example.com
if (pattern.startsWith('*') && !pattern.startsWith('*.')) {
const baseDomain = pattern.slice(1); // *nevermind.cloud → nevermind.cloud
if (domain === baseDomain || domain === `*.${baseDomain}`) return true;
if (domain.endsWith(baseDomain) && domain.length > baseDomain.length) return true;
} }
// Check for wildcard match (*.example.com) // Standard wildcard: *.example.com matches sub.example.com and example.com
if (pattern.startsWith('*.')) { if (pattern.startsWith('*.')) {
const patternSuffix = pattern.slice(2); // Remove the "*." prefix const suffix = pattern.slice(2);
if (domain === suffix) return true;
// Check if domain ends with the pattern suffix and has at least one character before it return domain.endsWith(suffix) && domain.length > suffix.length;
return domain.endsWith(patternSuffix) && domain.length > patternSuffix.length;
} }
// No match
return false; return false;
} }
@@ -705,7 +733,9 @@ export class DcRouter {
const routeDomains = Array.isArray(route.match.domains) const routeDomains = Array.isArray(route.match.domains)
? route.match.domains ? route.match.domains
: [route.match.domains]; : [route.match.domains];
if (routeDomains.includes(domain)) return route.name; for (const pattern of routeDomains) {
if (this.isDomainMatch(domain, pattern)) return route.name;
}
} }
return undefined; return undefined;
} }

View File

@@ -0,0 +1,46 @@
import * as plugins from './plugins.js';
import { StorageManager } from './storage/index.js';
/**
* ICertManager implementation backed by StorageManager.
* Persists SmartAcme certificates under a /certs/ key prefix so they
* survive process restarts without re-hitting ACME.
*/
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
private keyPrefix = '/certs/';
constructor(private storageManager: StorageManager) {}
async init(): Promise<void> {}
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
if (!data) return null;
return new plugins.smartacme.Cert(data);
}
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
id: cert.id,
domainName: cert.domainName,
created: cert.created,
privateKey: cert.privateKey,
publicKey: cert.publicKey,
csr: cert.csr,
validUntil: cert.validUntil,
});
}
async deleteCertificate(domainName: string): Promise<void> {
await this.storageManager.delete(this.keyPrefix + domainName);
}
async close(): Promise<void> {}
async wipe(): Promise<void> {
const keys = await this.storageManager.list(this.keyPrefix);
for (const key of keys) {
await this.storageManager.delete(key);
}
}
}

View File

@@ -489,8 +489,12 @@ export class MetricsManager {
return { return {
connectionsByIP: new Map<string, number>(), connectionsByIP: new Map<string, number>(),
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [], topIPs: [] as Array<{ ip: string; count: number }>,
totalDataTransferred: { bytesIn: 0, bytesOut: 0 }, totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
throughputByIP: new Map<string, { in: number; out: number }>(),
requestsPerSecond: 0,
requestsTotal: 0,
}; };
} }
@@ -513,11 +517,25 @@ export class MetricsManager {
bytesOut: proxyMetrics.totals.bytesOut() bytesOut: proxyMetrics.totals.bytesOut()
}; };
// Get throughput history from Rust engine (up to 300 seconds)
const throughputHistory = proxyMetrics.throughput.history(300);
// Get per-IP throughput
const throughputByIP = proxyMetrics.throughput.byIP();
// Get HTTP request rates
const requestsPerSecond = proxyMetrics.requests.perSecond();
const requestsTotal = proxyMetrics.requests.total();
return { return {
connectionsByIP, connectionsByIP,
throughputRate, throughputRate,
topIPs, topIPs,
totalDataTransferred, totalDataTransferred,
throughputHistory,
throughputByIP,
requestsPerSecond,
requestsTotal,
}; };
}, 200); // Use 200ms cache for more frequent updates }, 200); // Use 200ms cache for more frequent updates
} }

View File

@@ -104,6 +104,22 @@ export class CertificateHandler {
} }
} }
// Check persisted cert data from StorageManager
if (status === 'unknown' && routeDomains.length > 0) {
for (const domain of routeDomains) {
if (expiryDate) break;
const cleanDomain = domain.replace(/^\*\.?/, '');
const certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
if (certData?.validUntil) {
expiryDate = new Date(certData.validUntil).toISOString();
if (certData.created) {
issuedAt = new Date(certData.created).toISOString();
}
issuer = 'smartacme-dns-01';
}
}
}
// Compute status from expiry date if we have one and status is still valid/unknown // Compute status from expiry date if we have one and status is still valid/unknown
if (expiryDate && (status === 'valid' || status === 'unknown')) { if (expiryDate && (status === 'valid' || status === 'unknown')) {
const expiry = new Date(expiryDate); const expiry = new Date(expiryDate);
@@ -124,6 +140,11 @@ export class CertificateHandler {
status = 'valid'; status = 'valid';
} }
// ACME/provision-function routes with no cert data are still provisioning
if (status === 'unknown' && (source === 'acme' || source === 'provision-function')) {
status = 'provisioning';
}
const canReprovision = source === 'acme' || source === 'provision-function'; const canReprovision = source === 'acme' || source === 'provision-function';
certificates.push({ certificates.push({

View File

@@ -85,11 +85,23 @@ export class SecurityHandler {
if (this.opsServerRef.dcRouterRef.metricsManager) { if (this.opsServerRef.dcRouterRef.metricsManager) {
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats(); const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// Convert per-IP throughput Map to serializable array
const throughputByIP: Array<{ ip: string; in: number; out: number }> = [];
if (networkStats.throughputByIP) {
for (const [ip, tp] of networkStats.throughputByIP) {
throughputByIP.push({ ip, in: tp.in, out: tp.out });
}
}
return { return {
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })), connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
throughputRate: networkStats.throughputRate, throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs, topIPs: networkStats.topIPs,
totalDataTransferred: networkStats.totalDataTransferred, totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [],
throughputByIP,
requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0,
}; };
} }
@@ -99,6 +111,10 @@ export class SecurityHandler {
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [], topIPs: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 }, totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [],
throughputByIP: [],
requestsPerSecond: 0,
requestsTotal: 0,
}; };
} }
) )

View File

@@ -255,6 +255,14 @@ export class StatsHandler {
const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats(); const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
const serverStats = await this.collectServerStats(); const serverStats = await this.collectServerStats();
// Build per-IP bandwidth lookup from throughputByIP
const ipBandwidth = new Map<string, { in: number; out: number }>();
if (stats.throughputByIP) {
for (const [ip, tp] of stats.throughputByIP) {
ipBandwidth.set(ip, { in: tp.in, out: tp.out });
}
}
metrics.network = { metrics.network = {
totalBandwidth: { totalBandwidth: {
in: stats.throughputRate.bytesInPerSecond, in: stats.throughputRate.bytesInPerSecond,
@@ -269,11 +277,11 @@ export class StatsHandler {
topEndpoints: stats.topIPs.map(ip => ({ topEndpoints: stats.topIPs.map(ip => ({
endpoint: ip.ip, endpoint: ip.ip,
requests: ip.count, requests: ip.count,
bandwidth: { bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
in: 0,
out: 0,
},
})), })),
throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0,
requestsTotal: stats.requestsTotal || 0,
}; };
})() })()
); );

View File

@@ -130,6 +130,9 @@ export interface INetworkMetrics {
out: number; out: number;
}; };
}>; }>;
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond?: number;
requestsTotal?: number;
} }
export interface IConnectionDetails { export interface IConnectionDetails {

View File

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

View File

@@ -49,6 +49,10 @@ export interface INetworkState {
throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number }; throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number };
totalBytes: { in: number; out: number }; totalBytes: { in: number; out: number };
topIPs: Array<{ ip: string; count: number }>; topIPs: Array<{ ip: string; count: number }>;
throughputByIP: Array<{ ip: string; in: number; out: number }>;
throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond: number;
requestsTotal: number;
lastUpdated: number; lastUpdated: number;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
@@ -147,6 +151,10 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
totalBytes: { in: 0, out: 0 }, totalBytes: { in: 0, out: 0 },
topIPs: [], topIPs: [],
throughputByIP: [],
throughputHistory: [],
requestsPerSecond: 0,
requestsTotal: 0,
lastUpdated: 0, lastUpdated: 0,
isLoading: false, isLoading: false,
error: null, error: null,
@@ -427,6 +435,10 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
? { in: networkStatsResponse.totalDataTransferred.bytesIn, out: networkStatsResponse.totalDataTransferred.bytesOut } ? { in: networkStatsResponse.totalDataTransferred.bytesIn, out: networkStatsResponse.totalDataTransferred.bytesOut }
: { in: 0, out: 0 }, : { in: 0, out: 0 },
topIPs: networkStatsResponse.topIPs || [], topIPs: networkStatsResponse.topIPs || [],
throughputByIP: networkStatsResponse.throughputByIP || [],
throughputHistory: networkStatsResponse.throughputHistory || [],
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
requestsTotal: networkStatsResponse.requestsTotal || 0,
lastUpdated: Date.now(), lastUpdated: Date.now(),
isLoading: false, isLoading: false,
error: null, error: null,
@@ -797,6 +809,10 @@ async function dispatchCombinedRefreshAction() {
}, },
totalBytes: network.totalBytes || { in: 0, out: 0 }, totalBytes: network.totalBytes || { in: 0, out: 0 },
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })), topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })),
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0,
lastUpdated: Date.now(), lastUpdated: Date.now(),
isLoading: false, isLoading: false,
error: null, error: null,
@@ -813,6 +829,10 @@ async function dispatchCombinedRefreshAction() {
}, },
totalBytes: network.totalBytes || { in: 0, out: 0 }, totalBytes: network.totalBytes || { in: 0, out: 0 },
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })), topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })),
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0,
lastUpdated: Date.now(), lastUpdated: Date.now(),
isLoading: false, isLoading: false,
error: null, error: null,

View File

@@ -52,8 +52,7 @@ export class OpsViewNetwork extends DeesElement {
private requestCountHistory = new Map<number, number>(); // Track requests per time bucket private requestCountHistory = new Map<number, number>(); // Track requests per time bucket
private trafficUpdateTimer: any = null; private trafficUpdateTimer: any = null;
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
private historyLoaded = false; // Whether server-side throughput history has been loaded
// Removed byte tracking - now using real-time data from SmartProxy
constructor() { constructor() {
super(); super();
@@ -111,6 +110,54 @@ export class OpsViewNetwork extends DeesElement {
this.lastTrafficUpdateTime = now; this.lastTrafficUpdateTime = now;
} }
/**
* Load server-side throughput history into the chart.
* Called once when history data first arrives from the Rust engine.
* This pre-populates the chart so users see historical data immediately
* instead of starting from all zeros.
*/
private loadThroughputHistory() {
const history = this.networkState.throughputHistory;
if (!history || history.length === 0) return;
this.historyLoaded = true;
// Convert history points to chart data format (bytes/sec → Mbit/s)
const historyIn = history.map(p => ({
x: new Date(p.timestamp).toISOString(),
y: Math.round((p.in * 8) / 1000000 * 10) / 10,
}));
const historyOut = history.map(p => ({
x: new Date(p.timestamp).toISOString(),
y: Math.round((p.out * 8) / 1000000 * 10) / 10,
}));
// Use history as the chart data, keeping the most recent 60 points (5 min window)
const sliceStart = Math.max(0, historyIn.length - 60);
this.trafficDataIn = historyIn.slice(sliceStart);
this.trafficDataOut = historyOut.slice(sliceStart);
// If fewer than 60 points, pad the front with zeros
if (this.trafficDataIn.length < 60) {
const now = Date.now();
const range = 5 * 60 * 1000;
const bucketSize = range / 60;
const padCount = 60 - this.trafficDataIn.length;
const firstTimestamp = this.trafficDataIn.length > 0
? new Date(this.trafficDataIn[0].x).getTime()
: now;
const padIn = Array.from({ length: padCount }, (_, i) => ({
x: new Date(firstTimestamp - ((padCount - i) * bucketSize)).toISOString(),
y: 0,
}));
const padOut = padIn.map(p => ({ ...p }));
this.trafficDataIn = [...padIn, ...this.trafficDataIn];
this.trafficDataOut = [...padOut, ...this.trafficDataOut];
}
}
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
viewHostCss, viewHostCss,
@@ -352,21 +399,6 @@ export class OpsViewNetwork extends DeesElement {
return `${size.toFixed(1)} ${units[unitIndex]}`; return `${size.toFixed(1)} ${units[unitIndex]}`;
} }
private calculateRequestsPerSecond(): number {
// Calculate from actual request data in the last minute
const oneMinuteAgo = Date.now() - 60000;
const recentRequests = this.networkRequests.filter(req => req.timestamp >= oneMinuteAgo);
const reqPerSec = Math.round(recentRequests.length / 60);
// Track history for trend (keep last 20 values)
this.requestsPerSecHistory.push(reqPerSec);
if (this.requestsPerSecHistory.length > 20) {
this.requestsPerSecHistory.shift();
}
return reqPerSec;
}
private calculateThroughput(): { in: number; out: number } { private calculateThroughput(): { in: number; out: number } {
// Use real throughput data from network state // Use real throughput data from network state
return { return {
@@ -376,16 +408,17 @@ export class OpsViewNetwork extends DeesElement {
} }
private renderNetworkStats(): TemplateResult { private renderNetworkStats(): TemplateResult {
const reqPerSec = this.calculateRequestsPerSecond(); // Use server-side requests/sec from SmartProxy's Rust engine
const reqPerSec = this.networkState.requestsPerSecond || 0;
const throughput = this.calculateThroughput(); const throughput = this.calculateThroughput();
const activeConnections = this.statsState.serverStats?.activeConnections || 0; const activeConnections = this.statsState.serverStats?.activeConnections || 0;
// Throughput data is now available in the stats tiles // Track requests/sec history for the trend sparkline
this.requestsPerSecHistory.push(reqPerSec);
// Use request count history for the requests/sec trend if (this.requestsPerSecHistory.length > 20) {
this.requestsPerSecHistory.shift();
}
const trendData = [...this.requestsPerSecHistory]; const trendData = [...this.requestsPerSecHistory];
// If we don't have enough data, pad with zeros
while (trendData.length < 20) { while (trendData.length < 20) {
trendData.unshift(0); trendData.unshift(0);
} }
@@ -398,7 +431,7 @@ export class OpsViewNetwork extends DeesElement {
type: 'number', type: 'number',
icon: 'plug', icon: 'plug',
color: activeConnections > 100 ? '#f59e0b' : '#22c55e', color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
description: `Total: ${this.statsState.serverStats?.totalConnections || 0}`, description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`,
actions: [ actions: [
{ {
name: 'View Details', name: 'View Details',
@@ -416,7 +449,7 @@ export class OpsViewNetwork extends DeesElement {
icon: 'chartLine', icon: 'chartLine',
color: '#3b82f6', color: '#3b82f6',
trendData: trendData, trendData: trendData,
description: `Average over last minute`, description: `Total: ${this.formatNumber(this.networkState.requestsTotal || 0)} requests`,
}, },
{ {
id: 'throughputIn', id: 'throughputIn',
@@ -463,19 +496,32 @@ export class OpsViewNetwork extends DeesElement {
return html``; return html``;
} }
// Build per-IP bandwidth lookup
const bandwidthByIP = new Map<string, { in: number; out: number }>();
if (this.networkState.throughputByIP) {
for (const entry of this.networkState.throughputByIP) {
bandwidthByIP.set(entry.ip, { in: entry.in, out: entry.out });
}
}
// Calculate total connections across all top IPs // Calculate total connections across all top IPs
const totalConnections = this.networkState.topIPs.reduce((sum, ipData) => sum + ipData.count, 0); const totalConnections = this.networkState.topIPs.reduce((sum, ipData) => sum + ipData.count, 0);
return html` return html`
<dees-table <dees-table
.data=${this.networkState.topIPs} .data=${this.networkState.topIPs}
.displayFunction=${(ipData: { ip: string; count: number }) => ({ .displayFunction=${(ipData: { ip: string; count: number }) => {
'IP Address': ipData.ip, const bw = bandwidthByIP.get(ipData.ip);
'Connections': ipData.count, return {
'Percentage': totalConnections > 0 ? ((ipData.count / totalConnections) * 100).toFixed(1) + '%' : '0%', 'IP Address': ipData.ip,
})} 'Connections': ipData.count,
'Bandwidth In': bw ? this.formatBitsPerSecond(bw.in) : '0 bit/s',
'Bandwidth Out': bw ? this.formatBitsPerSecond(bw.out) : '0 bit/s',
'Share': totalConnections > 0 ? ((ipData.count / totalConnections) * 100).toFixed(1) + '%' : '0%',
};
}}
heading1="Top Connected IPs" heading1="Top Connected IPs"
heading2="IPs with most active connections" heading2="IPs with most active connections and bandwidth"
.pagination=${false} .pagination=${false}
dataName="ip" dataName="ip"
></dees-table> ></dees-table>
@@ -515,13 +561,10 @@ export class OpsViewNetwork extends DeesElement {
} }
} }
// Generate traffic data based on request history // Load server-side throughput history into chart (once)
this.updateTrafficData(); if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
} this.loadThroughputHistory();
}
private updateTrafficData() {
// This method is called when network data updates
// The actual chart updates are handled by the timer calling addTrafficDataPoint()
} }
private startTrafficUpdateTimer() { private startTrafficUpdateTimer() {