Compare commits

..

4 Commits

Author SHA1 Message Date
ec0348a83c v11.12.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-03-27 18:46:11 +00:00
6c4adf70c7 feat(web-ui): pause dashboard polling, sockets, and chart updates when the tab is hidden 2026-03-27 18:46:11 +00:00
29d6076355 v11.11.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-03-26 16:21:45 +00:00
fa96a41e68 feat(docker,cache,proxy): improve container runtime defaults and add configurable connection limits 2026-03-26 16:21:45 +00:00
18 changed files with 563 additions and 374 deletions

View File

@@ -18,9 +18,17 @@ WORKDIR /app
COPY --from=build /app /app COPY --from=build /app /app
ENV DCROUTER_MODE=OCI_CONTAINER ENV DCROUTER_MODE=OCI_CONTAINER
ENV DCROUTER_HEAP_SIZE=512
ENV UV_THREADPOOL_SIZE=16
RUN pnpm install -g @servezone/healthy RUN pnpm install -g @servezone/healthy
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ] HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
EXPOSE 80 LABEL org.opencontainers.image.title="dcrouter" \
CMD ["npm", "start"] org.opencontainers.image.description="Multi-service datacenter gateway" \
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
# HTTP/HTTPS, SMTP/Submission/SMTPS, DNS, RADIUS, OpsServer, RemoteIngress, dynamic range
EXPOSE 80 443 25 587 465 53/tcp 53/udp 1812/udp 1813/udp 3000 8443 29000-30000
CMD ["sh", "-c", "node --max_old_space_size=${DCROUTER_HEAP_SIZE} ./cli.js"]

View File

@@ -1,5 +1,22 @@
# Changelog # Changelog
## 2026-03-27 - 11.12.0 - feat(web-ui)
pause dashboard polling, sockets, and chart updates when the tab is hidden
- replace interval-based auto-refresh with scheduled actions using visibility-aware auto-pause
- disconnect and reconnect the TypedSocket on tab visibility changes to avoid background log buildup
- batch pushed log entries per animation frame and add an in-flight refresh guard to reduce unnecessary re-renders and overlapping requests
- update state subscriptions to use select() and document the new tab visibility optimization behavior
- bump smartdb, smartproxy, smartstate, remoteingress, dees-element, and tstest dependencies
## 2026-03-26 - 11.11.0 - feat(docker,cache,proxy)
improve container runtime defaults and add configurable connection limits
- replace the embedded cache backend integration from smartmongo LocalTsmDb to smartdb LocalSmartDb
- add OCI container settings for heap size, threadpool size, expanded exposed ports, image metadata, and a direct node startup command
- introduce startup checks for file descriptor limits and warn when container nofile limits are too low for production
- set gateway-oriented SmartProxy default limits and allow max connections, per-IP connections, and rate limits to be configured through OCI environment variables
## 2026-03-26 - 11.10.7 - fix(sms) ## 2026-03-26 - 11.10.7 - fix(sms)
update sms service to use async ProjectInfo initialization update sms service to use async ProjectInfo initialization

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "11.10.7", "version": "11.12.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": {
@@ -13,7 +13,7 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test": "(tstest test/ --logfile --timeout 60)", "test": "(tstest test/ --logfile --timeout 60)",
"start": "(node --max_old_space_size=250 ./cli.js)", "start": "(node ./cli.js)",
"startTs": "(node cli.ts.js)", "startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)", "build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
"build:docker": "tsdocker build --verbose", "build:docker": "tsdocker build --verbose",
@@ -25,7 +25,7 @@
"@git.zone/tsbuild": "^4.4.0", "@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.0", "@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.2", "@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.1", "@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2", "@git.zone/tswatch": "^3.3.2",
"@types/node": "^25.5.0" "@types/node": "^25.5.0"
}, },
@@ -36,33 +36,33 @@
"@api.global/typedsocket": "^4.1.2", "@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0", "@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.49.0", "@design.estate/dees-catalog": "^3.49.0",
"@design.estate/dees-element": "^2.2.3", "@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0", "@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0", "@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3", "@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^9.3.0", "@push.rocks/smartacme": "^9.3.0",
"@push.rocks/smartdata": "^7.1.3", "@push.rocks/smartdata": "^7.1.3",
"@push.rocks/smartdb": "^2.0.0",
"@push.rocks/smartdns": "^7.9.0", "@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartfs": "^1.5.0",
"@push.rocks/smartguard": "^3.1.0", "@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1", "@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.2.1", "@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartmetrics": "^3.0.3", "@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmongo": "^5.1.1",
"@push.rocks/smartmta": "^5.3.1", "@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.5.2",
"@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": "^26.2.4", "@push.rocks/smartproxy": "^27.0.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",
"@push.rocks/smartstate": "^2.2.1", "@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0", "@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.14.3", "@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0", "@tsclass/tsclass": "^9.5.0",
"lru-cache": "^11.2.7", "lru-cache": "^11.2.7",
"uuid": "^13.0.0" "uuid": "^13.0.0"

513
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -83,7 +83,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
### 💾 Persistent Storage & Caching ### 💾 Persistent Storage & Caching
- **Multiple storage backends**: filesystem, custom functions, or in-memory - **Multiple storage backends**: filesystem, custom functions, or in-memory
- **Embedded cache database** via smartdata + LocalTsmDb (MongoDB-compatible) - **Embedded cache database** via smartdata + smartdb (MongoDB-compatible)
- **Automatic TTL-based cleanup** for cached emails and IP reputation data - **Automatic TTL-based cleanup** for cached emails and IP reputation data
### 🖥️ OpsServer Dashboard ### 🖥️ OpsServer Dashboard
@@ -93,6 +93,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning - **Domain-centric certificate overview** with backoff status and one-click reprovisioning
- **Remote ingress management** with connection token generation and one-click copy - **Remote ingress management** with connection token generation and one-click copy
- **Read-only configuration display** — DcRouter is configured through code - **Read-only configuration display** — DcRouter is configured through code
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
### 🔧 Programmatic API Client ### 🔧 Programmatic API Client
- **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods - **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods
@@ -340,7 +341,7 @@ graph TB
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management | | **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) | | **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
| **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) | | **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) |
| **CacheDb** | `@push.rocks/smartdata` | Embedded MongoDB-compatible database (LocalTsmDb) for persistent caching | | **CacheDb** | `@push.rocks/smartdb` | Embedded MongoDB-compatible database (LocalSmartDb) for persistent caching |
### How It Works ### How It Works
@@ -1066,7 +1067,7 @@ Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, I
### Cache Database ### Cache Database
An embedded MongoDB-compatible database (via smartdata + LocalTsmDb) for persistent caching with automatic TTL cleanup: An embedded MongoDB-compatible database (via smartdata + smartdb) for persistent caching with automatic TTL cleanup:
```typescript ```typescript
cacheConfig: { cacheConfig: {
@@ -1406,37 +1407,58 @@ tstest test/test.opsserver-api.ts --verbose --timeout 60
## Docker / OCI Container Deployment ## Docker / OCI Container Deployment
DcRouter ships with a `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file). DcRouter ships with a production-ready `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. The container image includes tini as PID 1 (via the base image), proper health checks, and configurable resource limits. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file).
### Running with Docker ### Running with Docker
```bash ```bash
docker run -d \ docker run -d \
-e DCROUTER_MODE=OCI_CONTAINER \ --ulimit nofile=65536:65536 \
-e DCROUTER_TLS_EMAIL=admin@example.com \ -e DCROUTER_TLS_EMAIL=admin@example.com \
-e DCROUTER_PUBLIC_IP=203.0.113.1 \ -e DCROUTER_PUBLIC_IP=203.0.113.1 \
-e DCROUTER_DNS_NS_DOMAINS=ns1.example.com,ns2.example.com \ -e DCROUTER_DNS_NS_DOMAINS=ns1.example.com,ns2.example.com \
-e DCROUTER_DNS_SCOPES=example.com \ -e DCROUTER_DNS_SCOPES=example.com \
-p 80:80 -p 443:443 -p 25:25 -p 53:53/udp -p 3000:3000 \ -p 80:80 -p 443:443 -p 25:25 -p 587:587 -p 465:465 \
-p 53:53/udp -p 3000:3000 -p 8443:8443 \
code.foss.global/serve.zone/dcrouter:latest code.foss.global/serve.zone/dcrouter:latest
``` ```
> ⚡ **Production tip:** Always set `--ulimit nofile=65536:65536` for production deployments. DcRouter will log a warning at startup if the file descriptor limit is below 65536.
### Environment Variables ### Environment Variables
| Variable | Description | Example | | Variable | Description | Default | Example |
|----------|-------------|---------| |----------|-------------|---------|---------|
| `DCROUTER_MODE` | Set to `OCI_CONTAINER` to enable container mode | `OCI_CONTAINER` | | `DCROUTER_MODE` | Container mode (set automatically in image) | `OCI_CONTAINER` | — |
| `DCROUTER_CONFIG_PATH` | Path to a JSON config file (loaded as base, env vars override) | `/config/dcrouter.json` | | `DCROUTER_CONFIG_PATH` | Path to JSON config file (env vars override) | — | `/config/dcrouter.json` |
| `DCROUTER_BASE_DIR` | Override base data directory | `/data/dcrouter` | | `DCROUTER_BASE_DIR` | Base data directory | `~/.serve.zone/dcrouter` | `/data/dcrouter` |
| `DCROUTER_TLS_EMAIL` | ACME contact email | `admin@example.com` | | `DCROUTER_TLS_EMAIL` | ACME contact email | — | `admin@example.com` |
| `DCROUTER_TLS_DOMAIN` | Primary TLS domain | `example.com` | | `DCROUTER_TLS_DOMAIN` | Primary TLS domain | — | `example.com` |
| `DCROUTER_PUBLIC_IP` | Public IP for DNS records | `203.0.113.1` | | `DCROUTER_PUBLIC_IP` | Public IP for DNS records | — | `203.0.113.1` |
| `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | `198.51.100.1,198.51.100.2` | | `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | — | `198.51.100.1,198.51.100.2` |
| `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | `ns1.example.com,ns2.example.com` | | `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | — | `ns1.example.com,ns2.example.com` |
| `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | `example.com,other.com` | | `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | — | `example.com,other.com` |
| `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | `mail.example.com` | | `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | — | `mail.example.com` |
| `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | `25,587,465` | | `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | — | `25,587,465` |
| `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` | | `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` | `false` |
| `DCROUTER_HEAP_SIZE` | Node.js V8 heap size in MB | `512` | `1024` |
| `DCROUTER_MAX_CONNECTIONS` | Global max concurrent connections | `50000` | `100000` |
| `DCROUTER_MAX_CONNECTIONS_PER_IP` | Max connections per source IP | `100` | `200` |
| `DCROUTER_CONNECTION_RATE_LIMIT` | Max new connections/min per IP | `600` | `1200` |
### Exposed Ports
The container exposes all service ports:
| Port(s) | Protocol | Service |
|---------|----------|---------|
| 80, 443 | TCP | HTTP/HTTPS (SmartProxy) |
| 25, 587, 465 | TCP | SMTP, Submission, SMTPS |
| 53 | TCP/UDP | DNS |
| 1812, 1813 | UDP | RADIUS auth/acct |
| 3000 | TCP | OpsServer dashboard |
| 8443 | TCP | Remote ingress tunnels |
| 2900030000 | TCP | Dynamic port range |
### Building the Image ### Building the Image

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '11.10.7', version: '11.12.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

@@ -15,15 +15,15 @@ export interface ICacheDbOptions {
} }
/** /**
* CacheDb - Wrapper around LocalTsmDb and smartdata * CacheDb - Wrapper around LocalSmartDb and smartdata
* *
* Provides persistent caching using smartdata as the ORM layer * Provides persistent caching using smartdata as the ORM layer
* and LocalTsmDb as the embedded database engine. * and LocalSmartDb as the embedded database engine.
*/ */
export class CacheDb { export class CacheDb {
private static instance: CacheDb | null = null; private static instance: CacheDb | null = null;
private localTsmDb!: plugins.smartmongo.LocalTsmDb; private localSmartDb!: plugins.smartdb.LocalSmartDb;
private smartdataDb!: plugins.smartdata.SmartdataDb; private smartdataDb!: plugins.smartdata.SmartdataDb;
private options: Required<ICacheDbOptions>; private options: Required<ICacheDbOptions>;
private isStarted: boolean = false; private isStarted: boolean = false;
@@ -55,8 +55,8 @@ export class CacheDb {
/** /**
* Start the cache database * Start the cache database
* - Initializes LocalTsmDb with file persistence * - Initializes LocalSmartDb with file persistence
* - Connects smartdata to the LocalTsmDb via Unix socket * - Connects smartdata to the LocalSmartDb via Unix socket
*/ */
public async start(): Promise<void> { public async start(): Promise<void> {
if (this.isStarted) { if (this.isStarted) {
@@ -68,16 +68,16 @@ export class CacheDb {
// Ensure storage directory exists // Ensure storage directory exists
await plugins.fsUtils.ensureDir(this.options.storagePath); await plugins.fsUtils.ensureDir(this.options.storagePath);
// Create LocalTsmDb instance // Create LocalSmartDb instance
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({ this.localSmartDb = new plugins.smartdb.LocalSmartDb({
folderPath: this.options.storagePath, folderPath: this.options.storagePath,
}); });
// Start LocalTsmDb and get connection info // Start LocalSmartDb and get connection info
const connectionInfo = await this.localTsmDb.start(); const connectionInfo = await this.localSmartDb.start();
if (this.options.debug) { if (this.options.debug) {
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`); logger.log('debug', `LocalSmartDb started with URI: ${connectionInfo.connectionUri}`);
} }
// Initialize smartdata with the connection URI // Initialize smartdata with the connection URI
@@ -109,9 +109,9 @@ export class CacheDb {
await this.smartdataDb.close(); await this.smartdataDb.close();
} }
// Stop LocalTsmDb // Stop LocalSmartDb
if (this.localTsmDb) { if (this.localSmartDb) {
await this.localTsmDb.stop(); await this.localSmartDb.stop();
} }
this.isStarted = false; this.isStarted = false;

View File

@@ -528,10 +528,36 @@ export class DcRouter {
} }
public async start() { public async start() {
await this.checkSystemLimits();
logger.log('info', 'Starting DcRouter Services'); logger.log('info', 'Starting DcRouter Services');
await this.serviceManager.start(); await this.serviceManager.start();
this.logStartupSummary(); this.logStartupSummary();
} }
/**
* Detect OS-level resource limits and warn if they are too low for production use.
* This is detection only — no attempts to raise limits.
*/
private async checkSystemLimits(): Promise<void> {
try {
const fs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
const limitsContent = await fs.file('/proc/self/limits').encoding('utf8').read() as string;
const nofileLine = limitsContent.split('\n').find((line: string) => line.startsWith('Max open files'));
if (nofileLine) {
const parts = nofileLine.split(/\s{2,}/);
const softLimit = parseInt(parts[1], 10);
const hardLimit = parseInt(parts[2], 10);
if (softLimit < 65536) {
logger.log('warn', `File descriptor soft limit is ${softLimit} (hard: ${hardLimit}). ` +
`For production use, set --ulimit nofile=65536:65536 on the container runtime.`);
} else {
logger.log('info', `File descriptor limits: soft=${softLimit}, hard=${hardLimit}`);
}
}
} catch {
// Non-Linux or /proc not available — silently skip
}
}
/** /**
* Log comprehensive startup summary * Log comprehensive startup summary
@@ -708,9 +734,28 @@ export class DcRouter {
// Track cert entries loaded from cert store so we can populate certificateStatusMap after start // Track cert entries loaded from cert store so we can populate certificateStatusMap after start
const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = []; const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = [];
// Create SmartProxy configuration // Create SmartProxy configuration with sensible gateway defaults.
// User's smartProxyConfig overrides these defaults via spread.
const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = { const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
// --- dcrouter gateway defaults ---
maxConnectionsPerIP: 100,
connectionRateLimitPerMinute: 600,
socketTimeout: 120_000,
inactivityTimeout: 120_000,
keepAlive: true,
noDelay: true,
gracefulShutdownTimeout: 30_000,
// --- user overrides ---
...this.options.smartProxyConfig, ...this.options.smartProxyConfig,
// --- deep-merge defaults.security so user can override maxConnections ---
defaults: {
...this.options.smartProxyConfig?.defaults,
security: {
maxConnections: 50_000,
...this.options.smartProxyConfig?.defaults?.security,
},
},
// --- always set by dcrouter (after spread) ---
routes, routes,
acme: acmeConfig, acme: acmeConfig,
certStore: { certStore: {

View File

@@ -47,13 +47,13 @@ import * as qenv from '@push.rocks/qenv';
import * as smartacme from '@push.rocks/smartacme'; import * as smartacme from '@push.rocks/smartacme';
import * as smartdata from '@push.rocks/smartdata'; import * as smartdata from '@push.rocks/smartdata';
import * as smartdns from '@push.rocks/smartdns'; import * as smartdns from '@push.rocks/smartdns';
import * as smartfile from '@push.rocks/smartfile'; import * as smartfs from '@push.rocks/smartfs';
import * as smartguard from '@push.rocks/smartguard'; import * as smartguard from '@push.rocks/smartguard';
import * as smartjwt from '@push.rocks/smartjwt'; import * as smartjwt from '@push.rocks/smartjwt';
import * as smartlog from '@push.rocks/smartlog'; import * as smartlog from '@push.rocks/smartlog';
import * as smartmetrics from '@push.rocks/smartmetrics'; import * as smartmetrics from '@push.rocks/smartmetrics';
import * as smartmta from '@push.rocks/smartmta'; import * as smartmta from '@push.rocks/smartmta';
import * as smartmongo from '@push.rocks/smartmongo'; import * as smartdb from '@push.rocks/smartdb';
import * as smartnetwork from '@push.rocks/smartnetwork'; import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartpath from '@push.rocks/smartpath'; import * as smartpath from '@push.rocks/smartpath';
import * as smartproxy from '@push.rocks/smartproxy'; import * as smartproxy from '@push.rocks/smartproxy';
@@ -64,7 +64,7 @@ import * as smartrx from '@push.rocks/smartrx';
import * as smartunique from '@push.rocks/smartunique'; import * as smartunique from '@push.rocks/smartunique';
import * as taskbuffer from '@push.rocks/taskbuffer'; import * as taskbuffer from '@push.rocks/taskbuffer';
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, taskbuffer }; export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, taskbuffer };
// Define SmartLog types for use in error handling // Define SmartLog types for use in error handling
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug'; export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
@@ -90,7 +90,7 @@ export {
uuid, uuid,
} }
// Filesystem utilities (compatibility helpers for smartfile v13+) // Filesystem utilities
export const fsUtils = { export const fsUtils = {
/** /**
* Ensure a directory exists, creating it recursively if needed (sync) * Ensure a directory exists, creating it recursively if needed (sync)

View File

@@ -96,5 +96,27 @@ export function getOciContainerConfig(): IDcRouterOptions {
}; };
} }
// Connection capacity config
const maxConnections = process.env.DCROUTER_MAX_CONNECTIONS;
const maxConnectionsPerIP = process.env.DCROUTER_MAX_CONNECTIONS_PER_IP;
const connectionRateLimit = process.env.DCROUTER_CONNECTION_RATE_LIMIT;
if (maxConnections || maxConnectionsPerIP || connectionRateLimit) {
options.smartProxyConfig = {
...options.smartProxyConfig,
routes: options.smartProxyConfig?.routes || [],
...(maxConnectionsPerIP ? { maxConnectionsPerIP: parseInt(maxConnectionsPerIP, 10) } : {}),
...(connectionRateLimit ? { connectionRateLimitPerMinute: parseInt(connectionRateLimit, 10) } : {}),
...(maxConnections ? {
defaults: {
...options.smartProxyConfig?.defaults,
security: {
...options.smartProxyConfig?.defaults?.security,
maxConnections: parseInt(maxConnections, 10),
},
},
} : {}),
};
}
return options; return options;
} }

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '11.10.7', version: '11.12.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

@@ -1186,18 +1186,33 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
let socketClient: plugins.typedsocket.TypedSocket | null = null; let socketClient: plugins.typedsocket.TypedSocket | null = null;
const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter(); const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
// Batched log entry handler — buffers incoming entries and flushes once per animation frame
let logEntryBuffer: interfaces.data.ILogEntry[] = [];
let logFlushScheduled = false;
function flushLogEntries() {
logFlushScheduled = false;
if (logEntryBuffer.length === 0) return;
const current = logStatePart.getState()!;
const updated = [...current.recentLogs, ...logEntryBuffer];
logEntryBuffer = [];
// Cap at 2000 entries
if (updated.length > 2000) {
updated.splice(0, updated.length - 2000);
}
logStatePart.setState({ ...current, recentLogs: updated } as ILogState);
}
// Register handler for pushed log entries from the server // Register handler for pushed log entries from the server
socketRouter.addTypedHandler( socketRouter.addTypedHandler(
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushLogEntry>( new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushLogEntry>(
'pushLogEntry', 'pushLogEntry',
async (dataArg) => { async (dataArg) => {
const current = logStatePart.getState()!; logEntryBuffer.push(dataArg.entry);
const updated = [...current.recentLogs, dataArg.entry]; if (!logFlushScheduled) {
// Cap at 2000 entries logFlushScheduled = true;
if (updated.length > 2000) { requestAnimationFrame(flushLogEntries);
updated.splice(0, updated.length - 2000);
} }
logStatePart.setState({ ...current, recentLogs: updated } as ILogState);
return {}; return {};
} }
) )
@@ -1228,8 +1243,21 @@ async function disconnectSocket() {
} }
} }
// In-flight guard to prevent concurrent refresh requests
let isRefreshing = false;
// Combined refresh action for efficient polling // Combined refresh action for efficient polling
async function dispatchCombinedRefreshAction() { async function dispatchCombinedRefreshAction() {
if (isRefreshing) return;
isRefreshing = true;
try {
await dispatchCombinedRefreshActionInner();
} finally {
isRefreshing = false;
}
}
async function dispatchCombinedRefreshActionInner() {
const context = getActionContext(); const context = getActionContext();
if (!context.identity) return; if (!context.identity) return;
const currentView = uiStatePart.getState()!.activeView; const currentView = uiStatePart.getState()!.activeView;
@@ -1355,48 +1383,48 @@ async function dispatchCombinedRefreshAction() {
} }
} }
// Initialize auto-refresh // Create a proper action for the combined refresh so we can use createScheduledAction
let refreshInterval: NodeJS.Timeout | null = null; const combinedRefreshAction = statsStatePart.createAction<void>(async (statePartArg) => {
let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessary restarts await dispatchCombinedRefreshAction();
// Return current state — dispatchCombinedRefreshAction already updates all state parts directly
return statePartArg.getState()!;
});
// Initialize auto-refresh when UI state is ready // Scheduled refresh process with autoPause: 'visibility' — automatically pauses when tab is hidden
(() => { let refreshProcess: ReturnType<typeof statsStatePart.createScheduledAction> | null = null;
const startAutoRefresh = () => {
const uiState = uiStatePart.getState()!;
const loginState = loginStatePart.getState()!;
// Only start if conditions are met and not already running at the same rate const startAutoRefresh = () => {
if (uiState.autoRefresh && loginState.isLoggedIn) { const uiState = uiStatePart.getState()!;
// Check if we need to restart the interval (rate changed or not running) const loginState = loginStatePart.getState()!;
if (!refreshInterval || currentRefreshRate !== uiState.refreshInterval) {
stopAutoRefresh(); if (uiState.autoRefresh && loginState.isLoggedIn) {
currentRefreshRate = uiState.refreshInterval; // Dispose old process if interval changed or not running
refreshInterval = setInterval(() => { if (refreshProcess) {
// Use combined refresh action for efficiency refreshProcess.dispose();
dispatchCombinedRefreshAction(); refreshProcess = null;
}, uiState.refreshInterval);
}
} else {
stopAutoRefresh();
} }
}; refreshProcess = statsStatePart.createScheduledAction({
action: combinedRefreshAction,
const stopAutoRefresh = () => { payload: undefined,
if (refreshInterval) { intervalMs: uiState.refreshInterval,
clearInterval(refreshInterval); autoPause: 'visibility',
refreshInterval = null; });
currentRefreshRate = 0; } else {
if (refreshProcess) {
refreshProcess.dispose();
refreshProcess = null;
} }
}; }
};
// Watch for relevant changes only // Watch for relevant changes
let previousAutoRefresh = uiStatePart.getState()!.autoRefresh; let previousAutoRefresh = uiStatePart.getState()!.autoRefresh;
let previousRefreshInterval = uiStatePart.getState()!.refreshInterval; let previousRefreshInterval = uiStatePart.getState()!.refreshInterval;
let previousIsLoggedIn = loginStatePart.getState()!.isLoggedIn; let previousIsLoggedIn = loginStatePart.getState()!.isLoggedIn;
uiStatePart.state.subscribe((state) => { uiStatePart.select((s) => ({ autoRefresh: s.autoRefresh, refreshInterval: s.refreshInterval }))
// Only restart if relevant values changed .subscribe((state) => {
if (state.autoRefresh !== previousAutoRefresh || if (state.autoRefresh !== previousAutoRefresh ||
state.refreshInterval !== previousRefreshInterval) { state.refreshInterval !== previousRefreshInterval) {
previousAutoRefresh = state.autoRefresh; previousAutoRefresh = state.autoRefresh;
previousRefreshInterval = state.refreshInterval; previousRefreshInterval = state.refreshInterval;
@@ -1404,26 +1432,33 @@ let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessar
} }
}); });
loginStatePart.state.subscribe((state) => { loginStatePart.select((s) => s.isLoggedIn).subscribe((isLoggedIn) => {
// Only restart if login state changed if (isLoggedIn !== previousIsLoggedIn) {
if (state.isLoggedIn !== previousIsLoggedIn) { previousIsLoggedIn = isLoggedIn;
previousIsLoggedIn = state.isLoggedIn; startAutoRefresh();
startAutoRefresh();
// Connect/disconnect TypedSocket based on login state // Connect/disconnect TypedSocket based on login state
if (state.isLoggedIn) { if (isLoggedIn) {
connectSocket(); connectSocket();
} else { } else {
disconnectSocket(); disconnectSocket();
}
} }
}); }
});
// Initial start // Pause/resume WebSocket when tab visibility changes
startAutoRefresh(); document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// Connect TypedSocket if already logged in (e.g., persistent session) disconnectSocket();
if (loginStatePart.getState()!.isLoggedIn) { } else if (loginStatePart.getState()!.isLoggedIn) {
connectSocket(); connectSocket();
} }
})(); });
// Initial start
startAutoRefresh();
// Connect TypedSocket if already logged in (e.g., persistent session)
if (loginStatePart.getState()!.isLoggedIn) {
connectSocket();
}

View File

@@ -25,7 +25,7 @@ export class OpsViewCertificates extends DeesElement {
constructor() { constructor() {
super(); super();
const sub = appstate.certificateStatePart.state.subscribe((newState) => { const sub = appstate.certificateStatePart.select().subscribe((newState) => {
this.certState = newState; this.certState = newState;
}); });
this.rxSubscriptions.push(sub); this.rxSubscriptions.push(sub);

View File

@@ -28,7 +28,7 @@ export class OpsViewEmails extends DeesElement {
async connectedCallback() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => { this.stateSubscription = appstate.emailOpsStatePart.select().subscribe((state) => {
this.emails = state.emails; this.emails = state.emails;
this.isLoading = state.isLoading; this.isLoading = state.isLoading;
}); });

View File

@@ -47,10 +47,11 @@ export class OpsViewNetwork extends DeesElement {
// Track if we need to update the chart to avoid unnecessary re-renders // Track if we need to update the chart to avoid unnecessary re-renders
private lastChartUpdate = 0; private lastChartUpdate = 0;
private chartUpdateThreshold = 1000; // Minimum ms between chart updates private chartUpdateThreshold = 1000; // Minimum ms between chart updates
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 private historyLoaded = false; // Whether server-side throughput history has been loaded
private visibilityHandler: (() => void) | null = null;
constructor() { constructor() {
super(); super();
@@ -59,28 +60,42 @@ export class OpsViewNetwork extends DeesElement {
this.updateNetworkData(); this.updateNetworkData();
this.startTrafficUpdateTimer(); this.startTrafficUpdateTimer();
} }
async connectedCallback() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
// Pause/resume traffic timer when tab visibility changes
this.visibilityHandler = () => {
if (document.hidden) {
this.stopTrafficUpdateTimer();
} else {
this.startTrafficUpdateTimer();
}
};
document.addEventListener('visibilitychange', this.visibilityHandler);
// When network view becomes visible, ensure we fetch network data // When network view becomes visible, ensure we fetch network data
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null); await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
} }
async disconnectedCallback() { async disconnectedCallback() {
await super.disconnectedCallback(); await super.disconnectedCallback();
this.stopTrafficUpdateTimer(); this.stopTrafficUpdateTimer();
if (this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
} }
private subscribeToStateParts() { private subscribeToStateParts() {
// Subscribe and track unsubscribe functions // Subscribe and track unsubscribe functions
const statsUnsubscribe = appstate.statsStatePart.state.subscribe((state) => { const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => {
this.statsState = state; this.statsState = state;
this.updateNetworkData(); this.updateNetworkData();
}); });
this.rxSubscriptions.push(statsUnsubscribe); this.rxSubscriptions.push(statsUnsubscribe);
const networkUnsubscribe = appstate.networkStatePart.state.subscribe((state) => { const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
this.networkState = state; this.networkState = state;
this.updateNetworkData(); this.updateNetworkData();
}); });

View File

@@ -25,7 +25,7 @@ export class OpsViewRemoteIngress extends DeesElement {
constructor() { constructor() {
super(); super();
const sub = appstate.remoteIngressStatePart.state.subscribe((newState) => { const sub = appstate.remoteIngressStatePart.select().subscribe((newState) => {
this.riState = newState; this.riState = newState;
}); });
this.rxSubscriptions.push(sub); this.rxSubscriptions.push(sub);

View File

@@ -111,7 +111,7 @@ ts_web/
### State Management ### State Management
The app uses `@push.rocks/smartstate` with multiple state parts: The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled actions with `autoPause: 'visibility'`, and batched updates:
| State Part | Mode | Description | | State Part | Mode | Description |
|-----------|------|-------------| |-----------|------|-------------|
@@ -125,6 +125,16 @@ The app uses `@push.rocks/smartstate` with multiple state parts:
| `certificateStatePart` | Soft | Certificate list, summary, loading state | | `certificateStatePart` | Soft | Certificate list, summary, loading state |
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret | | `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
### Tab Visibility Optimization
The dashboard automatically pauses all background activity when the browser tab is hidden and resumes when visible:
- **Auto-refresh polling** uses `createScheduledAction` with `autoPause: 'visibility'` — stops HTTP requests while the tab is sleeping
- **In-flight guard** prevents concurrent refresh requests from piling up
- **WebSocket connection** disconnects when hidden and reconnects when visible, preventing log entry accumulation
- **Network traffic timer** pauses chart updates when the tab is backgrounded
- **Log entry batching** — incoming WebSocket log pushes are buffered and flushed once per animation frame to avoid per-entry re-renders
### Actions ### Actions
```typescript ```typescript

View File

@@ -38,7 +38,7 @@ class AppRouter {
} }
private setupStateSync(): void { private setupStateSync(): void {
appstate.uiStatePart.state.subscribe((uiState) => { appstate.uiStatePart.select().subscribe((uiState) => {
if (this.suppressStateUpdate) return; if (this.suppressStateUpdate) return;
const currentPath = window.location.pathname; const currentPath = window.location.pathname;