Compare commits

..

10 Commits

Author SHA1 Message Date
fbe845cd8e v11.12.4
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 22:38:29 +00:00
31413d28be fix(acme): use X509 certificate expiry when reporting ACME certificate validity 2026-03-27 22:38:29 +00:00
cd286cede6 v11.12.3
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 19:49:39 +00:00
36a3060cce fix(dcrouter): re-trigger auto certificate provisioning after SmartAcme becomes ready 2026-03-27 19:49:38 +00:00
d2b108317e v11.12.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-03-27 19:28:55 +00:00
dcd75f5e47 fix(dcrouter): guard auto certificate reprovisioning against unnamed routes 2026-03-27 19:28:55 +00:00
3d443fa147 v11.12.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-03-27 19:26:40 +00:00
2efdd2f16b fix(dcrouter): retry auto certificate provisioning after SmartAcme becomes ready 2026-03-27 19:26:39 +00:00
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
14 changed files with 448 additions and 336 deletions

View File

@@ -1,5 +1,41 @@
# Changelog # Changelog
## 2026-03-27 - 11.12.4 - fix(acme)
use X509 certificate expiry when reporting ACME certificate validity
- Parse the actual X509 validTo value from the PEM public certificate and fall back to SmartAcme's stored expiry if parsing fails
- Update reported certificate expiry data and event communication timestamps to use the verified validity date
- Bump @push.rocks/smartacme to ^9.3.1 and @push.rocks/smartproxy to ^27.1.0
## 2026-03-27 - 11.12.3 - fix(dcrouter)
re-trigger auto certificate provisioning after SmartAcme becomes ready
- clear certificate provisioning scheduler state before retrying startup-affected routes
- use route updates to re-run certificate provisioning for all current auto-cert routes
- remove the unused single-route domain lookup helper
## 2026-03-27 - 11.12.2 - fix(dcrouter)
guard auto certificate reprovisioning against unnamed routes
- Only re-triggers certificate provisioning for auto-cert routes when a route name is present.
- Prevents reprovision attempts from running with an undefined route name and reduces warning noise.
## 2026-03-27 - 11.12.1 - fix(dcrouter)
retry auto certificate provisioning after SmartAcme becomes ready
- detects certificates that failed during startup before the DNS-01 provider was available
- clears provisioning backoff and failed status for affected domains before retrying
- re-triggers auto certificate provisioning for SmartProxy routes once SmartAcme is ready
## 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) ## 2026-03-26 - 11.11.0 - feat(docker,cache,proxy)
improve container runtime defaults and add configurable connection limits improve container runtime defaults and add configurable connection limits

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "11.11.0", "version": "11.12.4",
"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": {
@@ -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,13 +36,13 @@
"@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.1",
"@push.rocks/smartdata": "^7.1.3", "@push.rocks/smartdata": "^7.1.3",
"@push.rocks/smartdb": "^1.0.1", "@push.rocks/smartdb": "^2.0.0",
"@push.rocks/smartdns": "^7.9.0", "@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfs": "^1.5.0", "@push.rocks/smartfs": "^1.5.0",
"@push.rocks/smartguard": "^3.1.0", "@push.rocks/smartguard": "^3.1.0",
@@ -53,16 +53,16 @@
"@push.rocks/smartnetwork": "^4.5.2", "@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.3.0", "@push.rocks/smartproxy": "^27.1.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"

475
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

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

View File

@@ -388,6 +388,23 @@ export class DcRouter {
await this.smartAcme.start(); await this.smartAcme.start();
this.smartAcmeReady = true; this.smartAcmeReady = true;
logger.log('info', 'SmartAcme DNS-01 provider is now ready'); logger.log('info', 'SmartAcme DNS-01 provider is now ready');
// Re-trigger certificate provisioning for all auto-cert routes.
// During startup, certProvisionFunction returned 'http01' (SmartAcme not ready),
// but Rust ACME is disabled when certProvisionFunction is set — so all domains
// failed silently (SmartProxy doesn't emit certificate-failed for this path).
// Calling updateRoutes() re-triggers provisionCertificatesViaCallback internally,
// which calls certProvisionFunction again — now with smartAcmeReady === true.
if (this.smartProxy) {
if (this.certProvisionScheduler) {
this.certProvisionScheduler.clear();
}
const currentRoutes = this.smartProxy.routeManager.getRoutes();
logger.log('info', `Re-triggering certificate provisioning for ${currentRoutes.length} routes`);
this.smartProxy.updateRoutes(currentRoutes).catch((err: any) => {
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
});
}
} }
}) })
.withStop(async () => { .withStop(async () => {
@@ -835,14 +852,22 @@ export class DcRouter {
const cert = await this.smartAcme!.getCertificateForDomain(domain, { const cert = await this.smartAcme!.getCertificateForDomain(domain, {
includeWildcard: !isWildcardDomain, includeWildcard: !isWildcardDomain,
}); });
if (cert.validUntil) { // Parse real X509 expiry from PEM (defense-in-depth over SmartAcme's estimate)
eventComms.setExpiryDate(new Date(cert.validUntil)); let realValidUntil = cert.validUntil;
if (cert.publicKey) {
try {
const x509 = new plugins.crypto.X509Certificate(cert.publicKey);
realValidUntil = new Date(x509.validTo).getTime();
} catch { /* fallback to SmartAcme's value */ }
}
if (realValidUntil) {
eventComms.setExpiryDate(new Date(realValidUntil));
} }
const result = { const result = {
id: cert.id, id: cert.id,
domainName: cert.domainName, domainName: cert.domainName,
created: cert.created, created: cert.created,
validUntil: cert.validUntil, validUntil: realValidUntil,
privateKey: cert.privateKey, privateKey: cert.privateKey,
publicKey: cert.publicKey, publicKey: cert.publicKey,
csr: cert.csr, csr: cert.csr,
@@ -1130,23 +1155,6 @@ export class DcRouter {
return false; return false;
} }
/**
* Find the first route name that matches a given domain
*/
private findRouteNameForDomain(domain: string): string | undefined {
if (!this.smartProxy) return undefined;
for (const route of this.smartProxy.routeManager.getRoutes()) {
if (!route.match.domains || !route.name) continue;
const routeDomains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
for (const pattern of routeDomains) {
if (this.isDomainMatch(domain, pattern)) return route.name;
}
}
return undefined;
}
/** /**
* Find ALL route names that match a given domain * Find ALL route names that match a given domain
*/ */

View File

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