diff --git a/app.js b/app.js
index 9990bfd..93c5509 100644
--- a/app.js
+++ b/app.js
@@ -125,6 +125,8 @@ manager.on('disconnect', (conn) => {
}
if (!manager.active?.connected) {
updateHealthBadge(null);
+ lastStatsInterestSent = null;
+ lastStatsInterestPeerId = null;
}
refreshFleetIfVisible();
if (isBootRestoring) return;
@@ -133,6 +135,8 @@ manager.on('disconnect', (conn) => {
if (!hasActiveConnection()) {
resetContainerList();
stopStatsInterval();
+ lastStatsInterestSent = null;
+ lastStatsInterestPeerId = null;
if (typeof manager.isReconnecting === 'function' && manager.isReconnecting()) {
// reconnecting banner is driven by 'reconnecting' events
return;
@@ -160,6 +164,26 @@ manager.on('connect', (conn) => {
{ latency: conn.latency, docker: conn.dockerHealth, status: conn.healthStatus },
conn
);
+ // Re-declare view interest after (re)connect so stats streams resume if needed
+ lastStatsInterestSent = null;
+ lastStatsInterestPeerId = null;
+ try {
+ syncStatsInterest({ force: true });
+ } catch {
+ // ignore
+ }
+ }
+});
+manager.on('active', (conn) => {
+ // Switching fleet peer: re-send interest to the new active host
+ lastStatsInterestSent = null;
+ lastStatsInterestPeerId = null;
+ if (conn?.connected) {
+ try {
+ syncStatsInterest({ force: true });
+ } catch {
+ // ignore
+ }
}
});
/** Throttle reconnect notices (standard mode only) */
@@ -419,6 +443,66 @@ function startStatsInterval() {
stopStatsInterval();
}
+/**
+ * Views that show live per-container CPU/memory (table or dashboard KPIs).
+ * Server only opens Docker stats streams while ≥1 peer reports interest.
+ */
+const STATS_INTEREST_VIEWS = new Set(['containers', 'dashboard', 'container-details']);
+
+/** @type {boolean|null} last interest sent for active peer (avoid spam) */
+let lastStatsInterestSent = null;
+/** @type {string|null} peer id we last told */
+let lastStatsInterestPeerId = null;
+
+/**
+ * Tell the active peer whether this UI needs live fleet stats.
+ * Call on view change and after connect. Leaving Containers/Dashboard tears
+ * down server-side Docker stats streams for this client.
+ * @param {{ force?: boolean }} [opts]
+ */
+function syncStatsInterest(opts = {}) {
+ const force = opts.force === true;
+ const conn = manager.active;
+ if (!conn?.connected) {
+ lastStatsInterestSent = null;
+ lastStatsInterestPeerId = null;
+ return;
+ }
+ const view =
+ typeof currentView === 'string'
+ ? currentView
+ : typeof window !== 'undefined' && typeof window.currentView === 'string'
+ ? window.currentView
+ : '';
+ const want = STATS_INTEREST_VIEWS.has(view);
+ if (
+ !force &&
+ lastStatsInterestSent === want &&
+ lastStatsInterestPeerId === conn.id
+ ) {
+ return;
+ }
+ lastStatsInterestSent = want;
+ lastStatsInterestPeerId = conn.id;
+ try {
+ conn
+ .request(Methods.setStatsInterest || 'setStatsInterest', {
+ active: want,
+ view: view || undefined,
+ })
+ .catch(() => {
+ // older servers may lack the method — ignore
+ lastStatsInterestSent = null;
+ });
+ } catch {
+ lastStatsInterestSent = null;
+ }
+}
+
+if (typeof window !== 'undefined') {
+ window.syncStatsInterest = syncStatsInterest;
+}
+
// Utility functions are now imported from uiUtils.js
@@ -1384,6 +1468,13 @@ function navigateToView(viewName, opts = {}) {
// Expose for auto-refresh poller
if (typeof window !== 'undefined') window.currentView = viewName;
+ // Demand-driven fleet stats: only collect while on Containers / Dashboard / details
+ try {
+ syncStatsInterest();
+ } catch {
+ // ignore
+ }
+
// Drop live streams / PTYs when leaving container details
if (leavingDetails) {
if (typeof stopDetailsLogs === 'function') stopDetailsLogs();
diff --git a/client/api.js b/client/api.js
index 65db1af..97406d5 100644
--- a/client/api.js
+++ b/client/api.js
@@ -385,6 +385,13 @@ export const api = {
return connOrActive(connection).request(Methods.updateStatsConfig, args)
},
+ setStatsInterest(active, view, connection) {
+ return connOrActive(connection).request(Methods.setStatsInterest, {
+ active: Boolean(active),
+ view: view || undefined,
+ })
+ },
+
getHostSnapshot(connection) {
return connOrActive(connection).request(Methods.getHostSnapshot, {})
},
diff --git a/config.js b/config.js
index 46c6732..20dccf5 100644
--- a/config.js
+++ b/config.js
@@ -4,17 +4,17 @@
export const CONFIG = {
// Stats collection (server/services/stats.js)
- // Round-robin one-shot samples — not full-fleet stream:true (Dozzle-style streams
- // keep dockerd busy ~1Hz × N containers). See Settings → Performance.
+ // Demand-driven: live stream:true only while a client is on Containers /
+ // Dashboard / container-details. Fully off otherwise. See Settings → Performance.
STATS: {
- INTERVAL_MS: 5000, // Min gap between allStats broadcasts
- ACTIVE_INTERVAL_MS: 5000, // Idle gap after each sample batch completes
- IDLE_INTERVAL_MS: 5000, // Reserved (peer-idle uses full pause)
- CACHE_TTL_MS: 2000, // Per-container broadcast cache TTL
- CONCURRENCY: 2, // Max parallel one-shot stats to dockerd
- SAMPLES_PER_TICK: 4, // Max containers sampled per tick (round-robin)
- LIST_INTERVAL_MS: 15000, // How often to re-list containers
- SMOOTHING_FACTOR: 0.2, // Client-side smoothing (if used)
+ INTERVAL_MS: 1000, // allStats push cadence while watching
+ ACTIVE_INTERVAL_MS: 1000, // alias for broadcast when watching
+ IDLE_INTERVAL_MS: 5000, // reserved
+ CACHE_TTL_MS: 1000,
+ CONCURRENCY: 2, // legacy (stream mode)
+ SAMPLES_PER_TICK: 4, // legacy (stream mode)
+ LIST_INTERVAL_MS: 10000, // re-list running containers while watching
+ SMOOTHING_FACTOR: 0.2,
},
// UI updates
diff --git a/docs/SERVER.md b/docs/SERVER.md
index 9db24ef..a7c6dc0 100644
--- a/docs/SERVER.md
+++ b/docs/SERVER.md
@@ -105,22 +105,14 @@ Subscribes to Engine events; fans out `push:dockerEvent` and may trigger list re
Periodic container stats → `push:allStats`; history buffer for charts.
-**Important:** peardock does **not** open perpetual per-container `stream: true` stats (Dozzle does, and [documents elevated dockerd CPU](https://dozzle.dev/guide/faq) as expected). Instead:
-
-1. **Round-robin one-shot** samples (`stream: false`) — at most `samplesPerTick` containers per batch
-2. **Post-batch idle gap** — next batch waits `collectIntervalMs` after the previous finishes (no overlapping sweeps)
-3. **Roster throttle** — `listContainers` only every `listIntervalMs`
-4. **Peer-idle pause** — no sampling when `peers.size === 0`
+**Demand-driven real-time stats:** clients call `setStatsInterest({ active, view })` while on **Containers**, **Dashboard**, or **container details**. Only then does the server open per-container Docker `stats({ stream: true })` attachments and push `push:allStats` to **watchers** (not every connected peer). Leaving those views (or disconnecting) tears streams down immediately — dockerd is not sampled in the background.
| Env / UI | Default | Meaning |
|----------|---------|---------|
-| Collect gap / `PEARDOCK_STATS_INTERVAL_MS` | 5000 | Idle after each sample batch |
-| Samples/tick / `PEARDOCK_STATS_SAMPLES_PER_TICK` | 4 | Max containers sampled per batch (main load knob) |
-| Concurrency / `PEARDOCK_STATS_CONCURRENCY` | 2 | Parallel one-shots within a batch |
-| List interval / `PEARDOCK_STATS_LIST_INTERVAL_MS` | 15000 | Roster refresh |
-| Broadcast / `PEARDOCK_STATS_BROADCAST_MS` | 5000 | Min gap between `push:allStats` |
+| UI update interval / `PEARDOCK_STATS_BROADCAST_MS` | 1000 | `push:allStats` cadence while watching |
+| Roster refresh / `PEARDOCK_STATS_LIST_INTERVAL_MS` | 10000 | Re-list running containers + attach streams |
-Runtime values persist in `peardock-stats.json` (cwd or `PEARDOCK_STATS_PATH`). RPC: `getStatsConfig` (viewer), `updateStatsConfig` (admin). Settings → **Performance** applies live. On hot hosts: raise collect gap, keep samples/tick at 2–4 and concurrency at 1–2.
+RPC: `setStatsInterest` (viewer), `getStatsConfig` (viewer), `updateStatsConfig` (admin). Settings → **Performance**.
### Metrics (`services/metrics.js`)
diff --git a/index.html b/index.html
index 35c1677..93f6528 100644
--- a/index.html
+++ b/index.html
@@ -2888,9 +2888,10 @@ services:
Docker stats collection Server
- Controls how the connected peer samples container CPU/memory for the fleet UI.
- Uses short one-shot polls (not perpetual streams) so dockerd load stays bounded.
- Requires an admin session to change. Values persist on the server as peardock-stats.json.
+ Live CPU/memory for the Containers table uses real-time Docker stats streams, but only while a client is on
+ Containers, Dashboard, or container details.
+ Leaving those views stops collection immediately so dockerd is not sampled in the background.
+ Requires admin to change timings. Persists as peardock-stats.json.
@@ -2903,44 +2904,22 @@ services:
Connect to a PearDock server to view or change stats collection settings.
-
Changes apply live on the server — no restart and no Save button.
+
Changes apply live — no restart. Collection is still off when no client is watching stats views.
-
-
-
Wait this long after a sample batch finishes before the next. Higher = less dockerd CPU.
+
+
+
How often push:allStats refreshes the table while watching (1 = near real-time).
-
-
-
Containers sampled each tick (round-robin). Main load control — do not set to fleet size.
-
-
-
-
-
Max parallel one-shot stats to dockerd within a tick. Keep at 1–2 on busy hosts.
-
-
-
-
-
Min gap between push:allStats to clients (can reuse last samples).
-
-
-
-
-
-
-
Roster refresh rate (start/stop detection for the sampler).
-
-
-
-
-
Reuse last sample for idle containers when broadcasting.
+
+
+
How often to re-list running containers and attach/detach streams.
-
One-time server log when this many containers are running.
+
One-time server log when this many streams would open.
+
+
+
+
+
diff --git a/server/handlers/system.js b/server/handlers/system.js
index c08d7f8..944bb73 100644
--- a/server/handlers/system.js
+++ b/server/handlers/system.js
@@ -18,6 +18,7 @@ import {
import {
getStatsConfig,
updateStatsConfig,
+ setPeerStatsInterest,
getStatsFilePath,
STATS_LIMITS,
} from '../services/stats.js'
@@ -210,6 +211,21 @@ export function registerSystemHandlers(session) {
return { success: true, config }
})
+ /**
+ * Client declares whether it needs live fleet stats for the current UI view.
+ * Server only opens Docker stats streams while ≥1 peer has active interest.
+ */
+ session.respond(
+ 'setStatsInterest',
+ async (args = {}) => {
+ const active = args.active === true || args.active === 1 || args.active === '1'
+ const view = args.view != null ? String(args.view) : undefined
+ const config = setPeerStatsInterest(session.id, active, { view })
+ return { success: true, active, config }
+ },
+ { hot: true }
+ )
+
session.respond('listSchedules', async () => {
return { success: true, schedules: listSchedules() }
})
diff --git a/server/rpc/register.js b/server/rpc/register.js
index c9c5f2f..dc181d6 100644
--- a/server/rpc/register.js
+++ b/server/rpc/register.js
@@ -21,6 +21,7 @@ import { registerBinaryStreamHandlers } from './binary-stream.js'
import { registerSuggestionHandlers } from '../handlers/suggestions.js'
import { registerTunnelHandlers } from '../handlers/tunnels.js'
import { registerAlertHandlers } from '../handlers/alerts.js'
+import { clearPeerStatsInterest } from '../services/stats.js'
/**
* @param {import('./session.js').PeerSession} session
@@ -54,6 +55,11 @@ export function registerAllHandlers(session) {
export function cleanupSession(session) {
cleanupTerminalOnClose(session)
cleanupLogsOnClose(session)
+ try {
+ clearPeerStatsInterest(session.id)
+ } catch {
+ // ignore
+ }
for (const [key, value] of session.state.entries()) {
if (key.startsWith('exec:') && value?.stream) {
try {
diff --git a/server/services/stats.js b/server/services/stats.js
index 1c3ef3c..4c346c9 100644
--- a/server/services/stats.js
+++ b/server/services/stats.js
@@ -1,18 +1,17 @@
/**
* Container stats collection and broadcast.
*
- * Strategy (tuned against dockerd load, informed by Dozzle's approach):
+ * Demand-driven real-time fleet stats:
*
- * - **One-shot** `stats({ stream: false })` — no perpetual stream:true attachments.
- * Dozzle uses long-lived streams for all running containers and documents
- * elevated dockerd CPU as expected; we intentionally avoid that.
- * - **Round-robin batches** — each tick samples at most `samplesPerTick`
- * containers (not the entire fleet). Full-fleet one-shot sweeps are often
- * *worse* than streams when sweep time > interval (dockerd never idles).
- * - **Post-sweep spacing** — next tick is scheduled only after the previous
- * finishes + collectIntervalMs (not a fixed setInterval that overlaps work).
- * - **Roster cache** — listContainers is not called every tick.
- * - **Peer-idle pause** — no collection when peers.size === 0.
+ * - Clients call `setStatsInterest({ active: true })` while on Containers /
+ * Dashboard / container-details (views that show CPU/mem).
+ * - Only then do we open per-container Docker stats streams and push
+ * `push:allStats` to interested peers.
+ * - When no peer wants stats, all streams are torn down immediately —
+ * dockerd is not sampled in the background.
+ *
+ * Streams (not round-robin one-shots) so the containers table updates live.
+ * Config knobs (Settings → Performance) still tune broadcast/list intervals.
*/
import fs from 'fs'
import path from 'path'
@@ -26,15 +25,18 @@ import logger from '../utils/logger.js'
const STATS_FILE =
process.env.PEARDOCK_STATS_PATH || path.join(process.cwd(), 'peardock-stats.json')
-/** Clamp helpers for RPC / env / disk */
export const STATS_LIMITS = Object.freeze({
- collectIntervalMs: { min: 1000, max: 60_000 },
- broadcastIntervalMs: { min: 1000, max: 60_000 },
+ /** How often we fan-out push:allStats while someone is watching */
+ broadcastIntervalMs: { min: 500, max: 30_000 },
+ /** How often to re-list running containers while watching */
+ listIntervalMs: { min: 2000, max: 120_000 },
+ /** Soft warn when many concurrent streams */
+ warnContainers: { min: 10, max: 10_000 },
+ /** Legacy keys kept for config normalize / UI compatibility */
+ collectIntervalMs: { min: 500, max: 60_000 },
concurrency: { min: 1, max: 32 },
samplesPerTick: { min: 1, max: 100 },
- listIntervalMs: { min: 2000, max: 120_000 },
cacheTtlMs: { min: 0, max: 30_000 },
- warnContainers: { min: 10, max: 10_000 },
})
function envInt(name, fallback) {
@@ -49,57 +51,24 @@ function clampInt(value, min, max, fallback) {
}
/**
- * Built-in defaults, then env overrides (bootstrap before disk / UI).
- * Conservative defaults keep dockerd idle between batches on busy hosts.
- * @returns {{
- * collectIntervalMs: number,
- * broadcastIntervalMs: number,
- * concurrency: number,
- * samplesPerTick: number,
- * listIntervalMs: number,
- * cacheTtlMs: number,
- * warnContainers: number,
- * }}
+ * Defaults tuned for real-time table updates while a peer is watching.
+ * Collection is fully off when no peer has stats interest.
*/
export function defaultStatsConfig() {
- const collectIntervalMs = clampInt(
- envInt(
- 'PEARDOCK_STATS_INTERVAL_MS',
- CONFIG.STATS?.ACTIVE_INTERVAL_MS ?? CONFIG.STATS?.INTERVAL_MS ?? 5000
- ),
- STATS_LIMITS.collectIntervalMs.min,
- STATS_LIMITS.collectIntervalMs.max,
- 5000
- )
const broadcastIntervalMs = clampInt(
- envInt('PEARDOCK_STATS_BROADCAST_MS', CONFIG.STATS?.INTERVAL_MS ?? 5000),
+ envInt(
+ 'PEARDOCK_STATS_BROADCAST_MS',
+ CONFIG.STATS?.INTERVAL_MS ?? CONFIG.STATS?.ACTIVE_INTERVAL_MS ?? 1000
+ ),
STATS_LIMITS.broadcastIntervalMs.min,
STATS_LIMITS.broadcastIntervalMs.max,
- 5000
- )
- const concurrency = clampInt(
- envInt('PEARDOCK_STATS_CONCURRENCY', CONFIG.STATS?.CONCURRENCY ?? 2),
- STATS_LIMITS.concurrency.min,
- STATS_LIMITS.concurrency.max,
- 2
- )
- const samplesPerTick = clampInt(
- envInt('PEARDOCK_STATS_SAMPLES_PER_TICK', CONFIG.STATS?.SAMPLES_PER_TICK ?? 4),
- STATS_LIMITS.samplesPerTick.min,
- STATS_LIMITS.samplesPerTick.max,
- 4
+ 1000
)
const listIntervalMs = clampInt(
- envInt('PEARDOCK_STATS_LIST_INTERVAL_MS', CONFIG.STATS?.LIST_INTERVAL_MS ?? 15_000),
+ envInt('PEARDOCK_STATS_LIST_INTERVAL_MS', CONFIG.STATS?.LIST_INTERVAL_MS ?? 10_000),
STATS_LIMITS.listIntervalMs.min,
STATS_LIMITS.listIntervalMs.max,
- 15_000
- )
- const cacheTtlMs = clampInt(
- envInt('PEARDOCK_STATS_CACHE_TTL_MS', CONFIG.STATS?.CACHE_TTL_MS ?? 2000),
- STATS_LIMITS.cacheTtlMs.min,
- STATS_LIMITS.cacheTtlMs.max,
- 2000
+ 10_000
)
const warnContainers = clampInt(
envInt('PEARDOCK_STATS_WARN_CONTAINERS', 80),
@@ -107,14 +76,21 @@ export function defaultStatsConfig() {
STATS_LIMITS.warnContainers.max,
80
)
+ // Legacy fields (UI / older peardock-stats.json) — unused in stream mode
+ const collectIntervalMs = clampInt(
+ envInt('PEARDOCK_STATS_INTERVAL_MS', broadcastIntervalMs),
+ STATS_LIMITS.collectIntervalMs.min,
+ STATS_LIMITS.collectIntervalMs.max,
+ broadcastIntervalMs
+ )
return {
- collectIntervalMs,
broadcastIntervalMs,
- concurrency,
- samplesPerTick,
listIntervalMs,
- cacheTtlMs,
warnContainers,
+ collectIntervalMs,
+ concurrency: 2,
+ samplesPerTick: 4,
+ cacheTtlMs: 1000,
}
}
@@ -137,15 +113,17 @@ function loadStatsDisk() {
function saveStatsDisk() {
try {
const payload = {
- version: 1,
+ version: 2,
+ mode: 'interest-stream',
updatedAt: new Date().toISOString(),
- collectIntervalMs: runtime.collectIntervalMs,
broadcastIntervalMs: runtime.broadcastIntervalMs,
+ listIntervalMs: runtime.listIntervalMs,
+ warnContainers: runtime.warnContainers,
+ // Keep legacy keys so older UIs still round-trip
+ collectIntervalMs: runtime.collectIntervalMs,
concurrency: runtime.concurrency,
samplesPerTick: runtime.samplesPerTick,
- listIntervalMs: runtime.listIntervalMs,
cacheTtlMs: runtime.cacheTtlMs,
- warnContainers: runtime.warnContainers,
}
const tmp = `${STATS_FILE}.${process.pid}.tmp`
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 })
@@ -156,37 +134,51 @@ function saveStatsDisk() {
}
/**
- * Normalize a partial config object with clamps.
* @param {object} partial
* @param {ReturnType} [base]
*/
export function normalizeStatsConfig(partial = {}, base = defaultStatsConfig()) {
const src = partial && typeof partial === 'object' ? partial : {}
- // Accept seconds from UI as collectIntervalSec / broadcastIntervalSec / listIntervalSec
- let collectMs = src.collectIntervalMs
- if (collectMs == null && src.collectIntervalSec != null) {
- collectMs = Number(src.collectIntervalSec) * 1000
- }
let broadcastMs = src.broadcastIntervalMs
if (broadcastMs == null && src.broadcastIntervalSec != null) {
broadcastMs = Number(src.broadcastIntervalSec) * 1000
}
+ // Older UI "collect interval" maps to broadcast cadence in stream mode
+ if (broadcastMs == null && src.collectIntervalMs != null) {
+ broadcastMs = src.collectIntervalMs
+ }
+ if (broadcastMs == null && src.collectIntervalSec != null) {
+ broadcastMs = Number(src.collectIntervalSec) * 1000
+ }
let listMs = src.listIntervalMs
if (listMs == null && src.listIntervalSec != null) {
listMs = Number(src.listIntervalSec) * 1000
}
+ const broadcastIntervalMs = clampInt(
+ broadcastMs ?? base.broadcastIntervalMs,
+ STATS_LIMITS.broadcastIntervalMs.min,
+ STATS_LIMITS.broadcastIntervalMs.max,
+ base.broadcastIntervalMs
+ )
return {
+ broadcastIntervalMs,
+ listIntervalMs: clampInt(
+ listMs ?? base.listIntervalMs,
+ STATS_LIMITS.listIntervalMs.min,
+ STATS_LIMITS.listIntervalMs.max,
+ base.listIntervalMs
+ ),
+ warnContainers: clampInt(
+ src.warnContainers ?? base.warnContainers,
+ STATS_LIMITS.warnContainers.min,
+ STATS_LIMITS.warnContainers.max,
+ base.warnContainers
+ ),
collectIntervalMs: clampInt(
- collectMs ?? base.collectIntervalMs,
+ src.collectIntervalMs ?? broadcastIntervalMs,
STATS_LIMITS.collectIntervalMs.min,
STATS_LIMITS.collectIntervalMs.max,
- base.collectIntervalMs
- ),
- broadcastIntervalMs: clampInt(
- broadcastMs ?? base.broadcastIntervalMs,
- STATS_LIMITS.broadcastIntervalMs.min,
- STATS_LIMITS.broadcastIntervalMs.max,
- base.broadcastIntervalMs
+ broadcastIntervalMs
),
concurrency: clampInt(
src.concurrency ?? base.concurrency,
@@ -200,30 +192,15 @@ export function normalizeStatsConfig(partial = {}, base = defaultStatsConfig())
STATS_LIMITS.samplesPerTick.max,
base.samplesPerTick
),
- listIntervalMs: clampInt(
- listMs ?? base.listIntervalMs,
- STATS_LIMITS.listIntervalMs.min,
- STATS_LIMITS.listIntervalMs.max,
- base.listIntervalMs
- ),
cacheTtlMs: clampInt(
src.cacheTtlMs ?? base.cacheTtlMs,
STATS_LIMITS.cacheTtlMs.min,
STATS_LIMITS.cacheTtlMs.max,
base.cacheTtlMs
),
- warnContainers: clampInt(
- src.warnContainers ?? base.warnContainers,
- STATS_LIMITS.warnContainers.min,
- STATS_LIMITS.warnContainers.max,
- base.warnContainers
- ),
}
}
-/**
- * Ensure disk overlay is applied once (env defaults first, then peardock-stats.json).
- */
export function ensureStatsConfigLoaded() {
if (diskLoaded) return runtime
diskLoaded = true
@@ -233,59 +210,123 @@ export function ensureStatsConfigLoaded() {
runtime = normalizeStatsConfig(disk, runtime)
logger.debug('Stats config loaded from disk', {
path: STATS_FILE,
- collectIntervalMs: runtime.collectIntervalMs,
- concurrency: runtime.concurrency,
+ broadcastIntervalMs: runtime.broadcastIntervalMs,
})
}
return runtime
}
+// ─── Interest (view-driven) ───────────────────────────────────────────────────
+
+/** Peer ids that currently want live fleet stats */
+const interestedPeers = new Set()
+/** @type {Map} peerId → view name (debug) */
+const peerViews = new Map()
+
/**
- * Public config snapshot for RPC / Settings UI.
- * @returns {object}
+ * Client reports whether it needs live container CPU/mem (Containers list, etc.).
+ * @param {string} peerId
+ * @param {boolean} active
+ * @param {{ view?: string }} [meta]
*/
+export function setPeerStatsInterest(peerId, active, meta = {}) {
+ if (!peerId) return getStatsConfig()
+ const was = interestedPeers.size
+ if (active) {
+ interestedPeers.add(peerId)
+ if (meta.view) peerViews.set(peerId, String(meta.view))
+ } else {
+ interestedPeers.delete(peerId)
+ peerViews.delete(peerId)
+ }
+ const now = interestedPeers.size
+ if (was === 0 && now > 0) {
+ resumeStatsCollection()
+ } else if (was > 0 && now === 0) {
+ pauseStatsCollection()
+ }
+ logger.debug('Stats interest', {
+ peerId: peerId.slice(0, 12),
+ active: Boolean(active),
+ view: meta.view || null,
+ watchers: now,
+ })
+ return getStatsConfig()
+}
+
+/**
+ * Drop interest for a peer (session close).
+ * @param {string} peerId
+ */
+export function clearPeerStatsInterest(peerId) {
+ if (!peerId || !interestedPeers.has(peerId)) return
+ setPeerStatsInterest(peerId, false)
+}
+
+export function getStatsInterestCount() {
+ return interestedPeers.size
+}
+
+// ─── Live state ───────────────────────────────────────────────────────────────
+
+/** @type {Record} */
+const containerStats = {}
+const statsCache = new Map()
+const containerActivity = new Map()
+
+/** @type {ReturnType|null} */
+let broadcastTimer = null
+/** @type {ReturnType|null} */
+let rosterTimer = null
+let collectionActive = false
+let unsubPeers = null
+let serviceArmed = false
+let lastBroadcast = 0
+let dockerDownLogged = false
+let dockerBackoffUntil = 0
+let rosterInFlight = false
+let largeFleetWarned = false
+
export function getStatsConfig() {
ensureStatsConfigLoaded()
return {
- collectIntervalMs: runtime.collectIntervalMs,
+ mode: 'interest-stream',
broadcastIntervalMs: runtime.broadcastIntervalMs,
+ listIntervalMs: runtime.listIntervalMs,
+ warnContainers: runtime.warnContainers,
+ collectIntervalMs: runtime.collectIntervalMs,
concurrency: runtime.concurrency,
samplesPerTick: runtime.samplesPerTick,
- listIntervalMs: runtime.listIntervalMs,
cacheTtlMs: runtime.cacheTtlMs,
- warnContainers: runtime.warnContainers,
- // Convenience for UI
collectIntervalSec: Math.round(runtime.collectIntervalMs / 1000),
broadcastIntervalSec: Math.round(runtime.broadcastIntervalMs / 1000),
listIntervalSec: Math.round(runtime.listIntervalMs / 1000),
- active: collectionLoopActive,
+ active: collectionActive,
peers: peers.size,
- runningTracked: rosterRunning.length,
+ watchers: interestedPeers.size,
+ streams: Object.values(containerStats).filter((s) => s.stream).length,
+ runningTracked: Object.keys(containerStats).length,
limits: { ...STATS_LIMITS },
path: STATS_FILE,
}
}
-/**
- * Apply partial config live (no process restart). Optionally persist to disk.
- * @param {object} partial
- * @param {{ persist?: boolean, replace?: boolean }} [opts]
- * @returns {object} getStatsConfig()
- */
export function updateStatsConfig(partial = {}, opts = {}) {
ensureStatsConfigLoaded()
const base = opts.replace ? defaultStatsConfig() : { ...runtime }
const next = normalizeStatsConfig(partial, base)
+ const broadcastChanged = next.broadcastIntervalMs !== runtime.broadcastIntervalMs
+ const listChanged = next.listIntervalMs !== runtime.listIntervalMs
runtime = next
if (opts.persist !== false) saveStatsDisk()
- // Interval is applied on the next schedule after a tick completes
+ if (collectionActive) {
+ if (broadcastChanged) armBroadcastLoop()
+ if (listChanged) armRosterLoop()
+ }
logger.info('Stats config updated', {
- collectIntervalMs: runtime.collectIntervalMs,
broadcastIntervalMs: runtime.broadcastIntervalMs,
- concurrency: runtime.concurrency,
- samplesPerTick: runtime.samplesPerTick,
listIntervalMs: runtime.listIntervalMs,
- cacheTtlMs: runtime.cacheTtlMs,
+ watchers: interestedPeers.size,
persist: opts.persist !== false,
})
return getStatsConfig()
@@ -295,38 +336,8 @@ export function getStatsFilePath() {
return STATS_FILE
}
-/** @type {Record} */
-const containerStats = {}
-const statsCache = new Map()
-const containerActivity = new Map()
+// ─── CPU / mem helpers ────────────────────────────────────────────────────────
-/** @type {ReturnType|null} */
-let loopTimer = null
-/** True while peer-connected collection loop is armed */
-let collectionLoopActive = false
-/** Peer-registry unsubscribe; set while stats service is armed */
-let unsubPeers = null
-/** True after startStatsBroadcast(); false after stopStatsBroadcast() */
-let serviceArmed = false
-let lastBroadcast = 0
-let dockerDownLogged = false
-let dockerBackoffUntil = 0
-/** Serialize ticks so overlapping async collects cannot pile up */
-let tickInFlight = false
-let largeFleetWarned = false
-
-/** Cached running containers from last listContainers */
-let rosterRunning = []
-/** @type {Set} */
-let rosterAllIds = new Set()
-let rosterFetchedAt = 0
-/** Round-robin cursor into rosterRunning */
-let rrIndex = 0
-
-/**
- * Docker Engine CPU % (same idea as `docker stats`).
- * One-shot stats populate precpu_stats after ~1s wait inside the engine.
- */
function calculateCPUPercent(stats) {
try {
const cpu = stats?.cpu_stats
@@ -344,7 +355,6 @@ function calculateCPUPercent(stats) {
if (systemDelta > 0 && cpuDelta >= 0) {
const pct = (cpuDelta / systemDelta) * cpuCount * 100.0
- // Clamp absurd spikes from clock glitches
if (!Number.isFinite(pct) || pct < 0) return 0
return Math.min(pct, cpuCount * 100)
}
@@ -354,9 +364,6 @@ function calculateCPUPercent(stats) {
}
}
-/**
- * Working-set style memory (closer to `docker stats` MEM USAGE).
- */
function calculateMemoryUsage(stats) {
try {
const mem = stats?.memory_stats
@@ -366,12 +373,10 @@ function calculateMemoryUsage(stats) {
const s = mem.stats || {}
let working = usage
if (s.inactive_file != null) {
- // cgroup v2
working = Math.max(0, usage - Number(s.inactive_file))
} else if (s.total_inactive_file != null) {
working = Math.max(0, usage - Number(s.total_inactive_file))
} else if (s.cache != null) {
- // cgroup v1
working = Math.max(0, usage - Number(s.cache))
}
return { usage: working, limit }
@@ -409,13 +414,6 @@ function sumBlkioBytes(stats) {
return { read, write }
}
-/**
- * Bytes/sec rates from consecutive cumulative counters.
- * @param {object} statsData
- * @param {{ rx: number, tx: number }} net
- * @param {{ read: number, write: number }} blk
- * @param {number} now
- */
function updateIoRates(statsData, net, blk, now) {
const prevT = statsData._ioTs || 0
const dt = prevT ? Math.max(0.001, (now - prevT) / 1000) : 0
@@ -445,11 +443,6 @@ function isContainerActive(statsData) {
return statsData.cpu > 0.5 || statsData.memory > 512 * 1024
}
-/**
- * IP from listContainers payload (avoids N× inspect).
- * @param {object} containerInfo
- * @returns {string}
- */
function ipFromListContainer(containerInfo) {
const nets = containerInfo?.NetworkSettings?.Networks
if (nets && typeof nets === 'object') {
@@ -463,6 +456,91 @@ function ipFromListContainer(containerInfo) {
return 'No IP Assigned'
}
+function applyDockerStatsSample(statsData, sample) {
+ const now = Date.now()
+ statsData.cpu = calculateCPUPercent(sample)
+ const mem = calculateMemoryUsage(sample)
+ statsData.memory = mem.usage
+ statsData.memoryLimit = mem.limit
+ updateIoRates(statsData, sumNetworkBytes(sample), sumBlkioBytes(sample), now)
+ statsData.updatedAt = now
+}
+
+// ─── Streams ──────────────────────────────────────────────────────────────────
+
+/**
+ * Attach live NDJSON stats stream for one running container.
+ * @param {object} statsData
+ * @param {import('dockerode').Container} container
+ */
+function attachStatsStream(statsData, container) {
+ if (statsData.stream || statsData._attaching) return
+ statsData._attaching = true
+ let buf = ''
+
+ const onChunk = (chunk) => {
+ try {
+ buf += chunk.toString('utf8')
+ let nl
+ while ((nl = buf.indexOf('\n')) >= 0) {
+ const line = buf.slice(0, nl).trim()
+ buf = buf.slice(nl + 1)
+ if (!line) continue
+ try {
+ applyDockerStatsSample(statsData, JSON.parse(line))
+ } catch {
+ // incomplete line
+ }
+ }
+ if (buf.length > 2 && buf.startsWith('{')) {
+ try {
+ const sample = JSON.parse(buf)
+ buf = ''
+ applyDockerStatsSample(statsData, sample)
+ } catch {
+ if (buf.length > 2_000_000) buf = ''
+ }
+ }
+ } catch (err) {
+ logger.debug('stats chunk parse failed', { id: statsData.id, error: err.message })
+ }
+ }
+
+ container
+ .stats({ stream: true })
+ .then((statsStream) => {
+ statsData._attaching = false
+ if (!collectionActive || interestedPeers.size === 0) {
+ try {
+ statsStream.destroy()
+ } catch {
+ // ignore
+ }
+ return
+ }
+ statsData.stream = statsStream
+ statsStream.on('data', onChunk)
+ statsStream.on('error', (err) => {
+ logger.debug('Stats stream error', { id: statsData.id?.slice?.(0, 12), error: err.message })
+ statsData.stream = null
+ })
+ statsStream.on('close', () => {
+ statsData.stream = null
+ })
+ statsStream.on('end', () => {
+ statsData.stream = null
+ })
+ })
+ .catch((err) => {
+ statsData._attaching = false
+ statsData.stream = null
+ logger.debug('Failed to start stats stream', {
+ id: statsData.id?.slice?.(0, 12),
+ error: err.message,
+ })
+ })
+}
+
function ensureStatsEntry(containerInfo) {
const id = containerInfo.Id
let statsData = containerStats[id]
@@ -482,11 +560,11 @@ function ensureStatsEntry(containerInfo) {
blkReadTotal: 0,
blkWriteTotal: 0,
ip: ipFromListContainer(containerInfo),
+ stream: null,
updatedAt: 0,
}
containerStats[id] = statsData
} else {
- // Refresh name / IP from list (no inspect)
const name = containerInfo.Names?.[0]?.replace(/^\//, '')
if (name) statsData.name = name
const ip = ipFromListContainer(containerInfo)
@@ -495,114 +573,23 @@ function ensureStatsEntry(containerInfo) {
return statsData
}
-function applyDockerStatsSample(statsData, sample) {
- const now = Date.now()
- statsData.cpu = calculateCPUPercent(sample)
- const mem = calculateMemoryUsage(sample)
- statsData.memory = mem.usage
- statsData.memoryLimit = mem.limit
- updateIoRates(statsData, sumNetworkBytes(sample), sumBlkioBytes(sample), now)
- statsData.updatedAt = now
-}
-
-/**
- * One-shot container stats. dockerode may return a Buffer/string or a brief stream;
- * normalize to a single JSON object.
- * @param {string} id
- * @returns {Promise