From ab6cea96c79b33238fa753419a5201b4d3bfcf21 Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Tue, 4 Aug 2026 21:04:55 -0400 Subject: [PATCH] Optimize Idle CPU/Dash --- app.js | 200 ++++++++++++++--------- server/core/peer-registry.js | 39 ++++- server/handlers/system.js | 95 +++++++++-- server/services/alerts.js | 15 +- server/services/events.js | 25 ++- server/services/stats.js | 298 ++++++++++++++++++++++++----------- test/ping-cache.test.js | 74 +++++++++ test/stats-idle.test.js | 90 +++++++++++ ui/ops-app.js | 68 +++++--- 9 files changed, 690 insertions(+), 214 deletions(-) create mode 100644 test/ping-cache.test.js create mode 100644 test/stats-idle.test.js diff --git a/app.js b/app.js index b4baf1c..9990bfd 100644 --- a/app.js +++ b/app.js @@ -107,11 +107,7 @@ function bindLivePeer(conn) { if (conn.capability) entry.capability = conn.capability; updateConnectionStatus(topicId, true); updateConnectionDisplay(topicId); - // Restart per-peer health UI loop (clears itself if peer drops) - if (entry.healthCheckInterval) { - clearInterval(entry.healthCheckInterval); - entry.healthCheckInterval = null; - } + // Sync health UI from live peer (manager health loop owns pings — no second interval) startHealthMonitoring(topicId); } @@ -407,14 +403,22 @@ const volumesStore = { // Expose volumes store to window for use by other modules window.volumesStore = volumesStore; let lastStatsUpdate = Date.now(); +/** + * Legacy no-ops: live container stats arrive via push:allStats from the server. + * Kept as named exports so call sites stay stable without a 500ms wake timer. + */ function stopStatsInterval() { if (statsInterval) { clearInterval(statsInterval); statsInterval = null; - console.log('[INFO] Stats interval stopped.'); } } +function startStatsInterval() { + // Clear any leftover timer from older sessions; do not schedule a new one. + stopStatsInterval(); +} + // Utility functions are now imported from uiUtils.js @@ -428,30 +432,6 @@ document.addEventListener('DOMContentLoaded', () => { }); } }); - -function startStatsInterval() { - // Guard: stop existing interval before starting a new one - stopStatsInterval(); - - // Only start if there's an active peer - if (!window.activePeer) { - console.warn('[WARN] No active peer; not starting stats interval.'); - return; - } - - // Increased interval to 500ms for better performance (was 100ms) - statsInterval = setInterval(() => { - if (window.activePeer) { - const now = Date.now(); - if (now - lastStatsUpdate >= 500) { // Ensure at least 500ms between updates - lastStatsUpdate = now; - } - } else { - console.warn('[WARN] No active peer; skipping stats request.'); - stopStatsInterval(); // Stop interval if peer is no longer active - } - }, 500); // Poll every 500ms for better performance (reduced from 100ms) -} const smoothedStats = {}; // Container-specific smoothing storage const historicalStats = {}; // Container-specific historical stats for charts const MAX_HISTORY_POINTS = 900; // ~30m at 2s broadcast interval @@ -2577,11 +2557,75 @@ document.addEventListener('DOMContentLoaded', () => { // Expose to window for onclick handlers window.navigateToView = navigateToView; +/** + * Dashboard RPC TTL cache — avoids re-hitting the Docker socket on every + * navigation to the dashboard when nothing has changed. + * Invalidated on peer switch and on relevant list/event responses. + */ +const DASHBOARD_TTL = { + systemInfo: 30_000, + systemDf: 30_000, + containers: 10_000, + images: 20_000, + networks: 20_000, + volumes: 20_000, +}; + +/** @type {{ peerId: string, at: Record }} */ +const dashboardRpcCache = { peerId: '', at: {} }; + +function dashboardCachePeerId() { + return manager.active?.id || window.activePeer?.id || ''; +} + +function touchDashboardCache(key) { + const peerId = dashboardCachePeerId(); + if (!peerId) return; + if (dashboardRpcCache.peerId !== peerId) { + dashboardRpcCache.peerId = peerId; + dashboardRpcCache.at = {}; + } + dashboardRpcCache.at[key] = Date.now(); +} + +/** + * @param {string} key + * @param {number} [ttlMs] + */ +function isDashboardCacheFresh(key, ttlMs) { + const peerId = dashboardCachePeerId(); + if (!peerId || dashboardRpcCache.peerId !== peerId) return false; + const at = dashboardRpcCache.at[key]; + if (!at) return false; + const ttl = ttlMs ?? DASHBOARD_TTL[key] ?? 15_000; + return Date.now() - at < ttl; +} + +/** + * @param {string|string[]|'all'} [keys] + */ +function invalidateDashboardCache(keys = 'all') { + if (keys === 'all') { + dashboardRpcCache.at = {}; + return; + } + const list = Array.isArray(keys) ? keys : [keys]; + for (const k of list) delete dashboardRpcCache.at[k]; +} + +window.invalidateDashboardCache = invalidateDashboardCache; + // Dashboard Functions function loadDashboard() { if (!hasActiveConnection() && !window.activePeer) { return; } + + const peerId = dashboardCachePeerId(); + if (peerId && dashboardRpcCache.peerId !== peerId) { + dashboardRpcCache.peerId = peerId; + dashboardRpcCache.at = {}; + } // Set up volumes subscription early to catch broadcasts if (!volumesStoreSubscription) { @@ -2627,22 +2671,27 @@ function loadDashboard() { } }) .catch(() => {}); - - // Load system info - sendCommand('getSystemInfo'); - sendCommand('getSystemDf'); - - // Load container stats for counts - sendCommand('listContainers'); - - // Load images count - sendCommand('listImages'); - - // Load networks count - sendCommand('listNetworks'); - - // Load volumes - sendCommand('listVolumes'); + + // Only re-fetch slices that are stale — same data when fresh, less Docker socket load. + // When fresh, rely on event pushes + auto-refresh for updates. + if (!isDashboardCacheFresh('systemInfo')) { + sendCommand('getSystemInfo'); + } + if (!isDashboardCacheFresh('systemDf')) { + sendCommand('getSystemDf'); + } + if (!isDashboardCacheFresh('containers')) { + sendCommand('listContainers'); + } + if (!isDashboardCacheFresh('images')) { + sendCommand('listImages'); + } + if (!isDashboardCacheFresh('networks')) { + sendCommand('listNetworks'); + } + if (!isDashboardCacheFresh('volumes')) { + sendCommand('listVolumes'); + } } window.loadDashboard = loadDashboard; @@ -2662,6 +2711,7 @@ function formatBytes(bytes) { function refreshSystemDf() { if (!hasActiveConnection()) return; + invalidateDashboardCache('systemDf'); sendCommand('getSystemDf'); } window.refreshSystemDf = refreshSystemDf; @@ -9773,6 +9823,7 @@ function handleRpcMessage(response, conn) { : null; if (rows) { applyContainerSnapshot(rows, topicId, { source: 'rpc' }); + touchDashboardCache('containers'); if (currentView === 'dashboard') { updateDashboardStats(containerFilterState.allContainers, null, null); } @@ -9869,11 +9920,13 @@ function handleRpcMessage(response, conn) { case 'systemInfo': console.log('[INFO] Handling system information...'); updateSystemInfo(response.data); + touchDashboardCache('systemInfo'); break; case 'images': console.log('[INFO] Handling images list...'); renderImages(response.data); + touchDashboardCache('images'); // Update dashboard stats if on dashboard view if (currentView === 'dashboard') { updateDashboardStats(null, response.data, null); @@ -9883,6 +9936,7 @@ function handleRpcMessage(response, conn) { case 'networks': console.log('[INFO] Handling networks list...'); renderNetworks(response.data); + touchDashboardCache('networks'); // Update dashboard stats if on dashboard view if (currentView === 'dashboard') { updateDashboardStats(null, null, response.data); @@ -9903,6 +9957,7 @@ function handleRpcMessage(response, conn) { if (volumesToRender !== null) { // Always update store first (this will trigger subscriptions) volumesStore.set(volumesToRender); + touchDashboardCache('volumes'); // Always render if on volumes view to ensure UI is updated if (currentView === 'volumes') { renderVolumes(volumesToRender); @@ -9945,6 +10000,13 @@ function handleRpcMessage(response, conn) { } case 'dockerEvent': { + // Fleet-changing events: force dashboard slices to re-fetch on next visit + const ev = response.data || response; + const t = String(ev?.Type || ev?.type || '').toLowerCase(); + if (t === 'container') invalidateDashboardCache('containers'); + else if (t === 'image') invalidateDashboardCache(['images', 'systemDf']); + else if (t === 'volume') invalidateDashboardCache(['volumes', 'systemDf']); + else if (t === 'network') invalidateDashboardCache('networks'); if (window.handleDockerEvent) { window.handleDockerEvent(response.data); } @@ -10015,6 +10077,7 @@ function handleRpcMessage(response, conn) { case 'systemDf': renderSystemDf(response.data); + touchDashboardCache('systemDf'); break; case 'containerTop': @@ -11248,8 +11311,12 @@ function updateConnectionDisplay(topicId) { : String(fullKey); } -// Health monitoring for connections (UI latency badge). Dead-link detection + reconnect -// is owned by ConnectionManager's health loop / PearDockConnection.ping(). +/** + * Sync connection-row health UI from the live peer. + * Ping / dead-link detection is owned solely by ConnectionManager's health loop + * (manager emits 'health' → updateConnectionDisplay). A second interval here used + * to double docker.version() load on the server every 10s. + */ function startHealthMonitoring(topicId) { const connection = connections[topicId]; if (!connection) return; @@ -11258,36 +11325,20 @@ function startHealthMonitoring(topicId) { connection.healthCheckInterval = null; } - const healthCheckInterval = setInterval(async () => { - const entry = connections[topicId]; - // Prefer manager's live socket if UI map is briefly stale after reconnect - const peer = entry?.peer?.connected - ? entry.peer + const peer = + connection.peer?.connected + ? connection.peer : manager.connections.get(topicId)?.connected ? manager.connections.get(topicId) : null; - if (!peer) { - clearInterval(healthCheckInterval); - if (entry) entry.healthCheckInterval = null; - return; - } - if (entry && entry.peer !== peer) entry.peer = peer; - try { - const ms = await peer.ping(); - entry.latency = ms; - entry.lastHealthCheck = Date.now(); - entry.healthStatus = ms < 5000 ? 'healthy' : 'slow'; - updateConnectionStatus(topicId, true); - updateConnectionDisplay(topicId); - } catch (err) { - entry.healthStatus = 'unhealthy'; - updateConnectionStatus(topicId, false); - updateConnectionDisplay(topicId); - // Do not clear forever — manager will disconnect + redial; bindLivePeer restarts this loop - } - }, 10000); + if (!peer) return; + if (connection.peer !== peer) connection.peer = peer; - connections[topicId].healthCheckInterval = healthCheckInterval; + connection.latency = peer.latency ?? connection.latency; + connection.healthStatus = peer.healthStatus || connection.healthStatus || 'healthy'; + connection.lastHealthCheck = peer.lastHealthCheck || connection.lastHealthCheck || Date.now(); + updateConnectionStatus(topicId, true); + updateConnectionDisplay(topicId); } // Switch between connections @@ -11336,6 +11387,7 @@ function switchConnection(topicId, opts = {}) { // Drop previous host's containers immediately so the table never mixes peers resetContainerList(); containerStore.topicId = manager.active?.id || topicId || ''; + invalidateDashboardCache('all'); console.log(`[INFO] Switched to connection: ${topicId}`); startStatsInterval(); sendCommand(Methods.listContainers); diff --git a/server/core/peer-registry.js b/server/core/peer-registry.js index 8d7e11d..fb75e47 100644 --- a/server/core/peer-registry.js +++ b/server/core/peer-registry.js @@ -9,20 +9,54 @@ export class PeerRegistry { constructor() { /** @type {Map} */ this.sessions = new Map() + /** @type {Set<(size: number, prevSize: number) => void>} */ + this._listeners = new Set() + } + + /** + * Subscribe to peer count changes (add / remove / clear). + * @param {(size: number, prevSize: number) => void} fn + * @returns {() => void} unsubscribe + */ + onChange(fn) { + if (typeof fn !== 'function') return () => {} + this._listeners.add(fn) + return () => { + this._listeners.delete(fn) + } + } + + /** + * @param {number} prevSize + */ + _emitChange(prevSize) { + const size = this.sessions.size + if (size === prevSize) return + for (const fn of this._listeners) { + try { + fn(size, prevSize) + } catch (err) { + log.debug('onChange listener failed', { error: err?.message }) + } + } } /** * @param {import('../rpc/session.js').PeerSession} session */ add(session) { + const prev = this.sessions.size this.sessions.set(session.id, session) + this._emitChange(prev) } /** * @param {string} id */ remove(id) { - this.sessions.delete(id) + const prev = this.sessions.size + const had = this.sessions.delete(id) + if (had) this._emitChange(prev) } /** @@ -46,6 +80,7 @@ export class PeerRegistry { * @param {unknown} payload */ broadcast(method, payload) { + if (this.sessions.size === 0) return for (const session of this.sessions.values()) { try { session.push(method, payload) @@ -60,6 +95,7 @@ export class PeerRegistry { } clear() { + const prev = this.sessions.size for (const session of this.sessions.values()) { try { session.destroy() @@ -68,6 +104,7 @@ export class PeerRegistry { } } this.sessions.clear() + this._emitChange(prev) } } diff --git a/server/handlers/system.js b/server/handlers/system.js index 9ae5c5e..032db24 100644 --- a/server/handlers/system.js +++ b/server/handlers/system.js @@ -20,20 +20,89 @@ import logger from '../utils/logger.js' /** @type {Map} */ const registryAuth = new Map() -export function registerSystemHandlers(session) { - session.respond('ping', async () => { - let dockerOk = false - let apiVersion = null - let osType = null - let error = null - try { +/** + * Cached Docker probe for high-frequency ping RPCs. + * Clients poll health every ~10s; version() is heavier than needed every tick. + * TTL keeps degraded detection responsive without hammering the socket. + */ +const PING_DOCKER_CACHE_TTL_MS = 10_000 +/** @type {{ at: number, ok: boolean, apiVersion: string|null, os: string|null, error: string|null }|null} */ +let pingDockerCache = null + +/** + * Lightweight Docker health for ping. Uses docker.ping() for liveness; refreshes + * apiVersion/os via version() only when cache is cold or after a failure. + * @returns {Promise<{ ok: boolean, apiVersion: string|null, os: string|null, error: string|null }>} + */ +export async function probeDockerForPing() { + const now = Date.now() + if ( + pingDockerCache && + now - pingDockerCache.at < PING_DOCKER_CACHE_TTL_MS && + pingDockerCache.ok + ) { + return { + ok: true, + apiVersion: pingDockerCache.apiVersion, + os: pingDockerCache.os, + error: null, + } + } + + let dockerOk = false + let apiVersion = pingDockerCache?.apiVersion ?? null + let osType = pingDockerCache?.os ?? null + let error = null + + try { + // Prefer cheap ping; fall back to version if ping is unavailable + if (typeof docker.ping === 'function') { + await docker.ping() + dockerOk = true + // Refresh version metadata when cache empty or expired + if (!apiVersion || !pingDockerCache || now - pingDockerCache.at >= PING_DOCKER_CACHE_TTL_MS) { + try { + const version = await docker.version() + apiVersion = version.ApiVersion || version.apiVersion || apiVersion + osType = version.Os || version.os || osType + } catch { + // ok remains true from ping; version is optional decoration + } + } + } else { const version = await docker.version() dockerOk = true apiVersion = version.ApiVersion || version.apiVersion || null osType = version.Os || version.os || null - } catch (err) { - error = err.message } + } catch (err) { + error = err.message || String(err) + dockerOk = false + } + + pingDockerCache = { + at: Date.now(), + ok: dockerOk, + apiVersion, + os: osType, + error, + } + return { + ok: dockerOk, + apiVersion, + os: osType, + error, + } +} + +/** Test helper: clear ping Docker probe cache */ +export function clearPingDockerCache() { + pingDockerCache = null +} + +export function registerSystemHandlers(session) { + session.respond('ping', async () => { + const dockerProbe = await probeDockerForPing() return { success: true, pong: Date.now(), @@ -41,10 +110,10 @@ export function registerSystemHandlers(session) { protocolVersion: PROTOCOL_VERSION, role: session.role, docker: { - ok: dockerOk, - apiVersion, - os: osType, - error, + ok: dockerProbe.ok, + apiVersion: dockerProbe.apiVersion, + os: dockerProbe.os, + error: dockerProbe.error, }, features: { swarm: isSwarmEnabled(), diff --git a/server/services/alerts.js b/server/services/alerts.js index a498723..507ea05 100644 --- a/server/services/alerts.js +++ b/server/services/alerts.js @@ -1073,10 +1073,18 @@ async function pollHealth() { return // skip further polls if docker is down } + // One listContainers for both container_health and stack_health (half the socket work) + /** @type {object[]} */ + let containers = [] + try { + containers = (await docker.listContainers({ all: true })) || [] + } catch (err) { + logger.debug('alerts: container list poll failed', { error: err.message }) + } + // Container health try { - const containers = await docker.listContainers({ all: true }) - for (const c of containers || []) { + for (const c of containers) { const health = c.Status?.match(/\((healthy|unhealthy|health: starting)\)/i)?.[1] || c.State // running/exited — Health may be in inspect only // Prefer Health from inspect-lite if present @@ -1098,10 +1106,9 @@ async function pollHealth() { // Stack / compose project health (group by com.docker.compose.project) try { - const containers = await docker.listContainers({ all: true }) /** @type {Map} */ const stacks = new Map() - for (const c of containers || []) { + for (const c of containers) { const project = c.Labels?.['com.docker.compose.project'] || c.Labels?.['com.docker.stack.namespace'] diff --git a/server/services/events.js b/server/services/events.js index b61d9e3..11ad04d 100644 --- a/server/services/events.js +++ b/server/services/events.js @@ -39,9 +39,11 @@ const CONTAINER_LIST_ACTIONS = new Set([ ]) function scheduleContainerListBroadcast() { + if (peers.size === 0) return if (containerListBroadcastTimer) return containerListBroadcastTimer = setTimeout(() => { containerListBroadcastTimer = null + if (peers.size === 0) return broadcastContainers().catch((err) => { logger.debug('container list broadcast failed', { error: err.message }) }) @@ -49,9 +51,11 @@ function scheduleContainerListBroadcast() { } function scheduleImageListBroadcast() { + if (peers.size === 0) return if (imageListBroadcastTimer) return imageListBroadcastTimer = setTimeout(() => { imageListBroadcastTimer = null + if (peers.size === 0) return broadcastImages().catch((err) => { logger.debug('image list broadcast failed', { error: err.message }) }) @@ -95,18 +99,13 @@ async function openEventStream() { } if (event.status === 'undefined') continue - logger.info('Docker event', { + logger.debug('Docker event', { status: event.status, id: event.id, type: event.Type, action: event.Action, }) - peers.broadcast(Pushes.dockerEvent, { - type: 'dockerEvent', - data: event, - }) - // Server-side alerting (webhooks) — independent of connected clients try { onDockerEvent(event) @@ -114,6 +113,14 @@ async function openEventStream() { logger.debug('alerts onDockerEvent error', { error: e?.message }) } + // Skip client fan-out when idle (no peers) — alerts already handled above + if (peers.size > 0) { + peers.broadcast(Pushes.dockerEvent, { + type: 'dockerEvent', + data: event, + }) + } + // Do not rebroadcast the full list on every exec_*/health_status event — // that thrashed clients (rows flicker / disappear on each refresh). if (event.Type === 'container') { @@ -127,7 +134,11 @@ async function openEventStream() { scheduleImageListBroadcast() } - if (event.Type === 'volume' && (event.Action === 'create' || event.Action === 'destroy')) { + if ( + peers.size > 0 && + event.Type === 'volume' && + (event.Action === 'create' || event.Action === 'destroy') + ) { const volumesResult = await docker.listVolumes() const volumesList = extractVolumesList(volumesResult) peers.broadcast(Pushes.volumes, { diff --git a/server/services/stats.js b/server/services/stats.js index 36ce46b..d3c0620 100644 --- a/server/services/stats.js +++ b/server/services/stats.js @@ -9,10 +9,14 @@ import { docker, extractIpAddress } from './docker.js' import { peers } from '../core/peer-registry.js' import { Pushes } from '../../shared/protocol.js' import { recordSample, pruneMissing } from './stats-history.js' +import { CONFIG } from '../../config.js' import logger from '../utils/logger.js' -const STATS_CACHE_TTL = 1000 -const STATS_BROADCAST_INTERVAL = 2000 +const STATS_CACHE_TTL = CONFIG.STATS?.CACHE_TTL_MS ?? 1000 +/** How often we tick collection when peers are connected */ +const STATS_COLLECT_INTERVAL = CONFIG.STATS?.ACTIVE_INTERVAL_MS ?? 1000 +/** Minimum gap between allStats broadcasts */ +const STATS_BROADCAST_INTERVAL = CONFIG.STATS?.INTERVAL_MS ?? 2000 /** @type {Record} */ const containerStats = {} @@ -20,6 +24,15 @@ const statsCache = new Map() const containerActivity = new Map() let intervalHandle = null +/** 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 /** * Docker Engine CPU % (same idea as `docker stats`). @@ -332,104 +345,199 @@ async function collectContainerStats() { } } -export function startStatsBroadcast() { - if (intervalHandle) return - let lastBroadcast = 0 - let dockerDownLogged = false - let dockerBackoffUntil = 0 - - intervalHandle = setInterval(async () => { - try { - const now = Date.now() - if (now < dockerBackoffUntil) return - - await collectContainerStats() - dockerDownLogged = false - - if (now - lastBroadcast < STATS_BROADCAST_INTERVAL) return - if (peers.size === 0) return - - const aggregatedStats = [] - for (const [containerId, statsData] of Object.entries(containerStats)) { - const cached = statsCache.get(containerId) - if ( - cached && - now - cached.timestamp < STATS_CACHE_TTL && - !isContainerActive(statsData) - ) { - aggregatedStats.push(cached.data) - continue - } - if (isContainerActive(statsData)) { - containerActivity.set(containerId, now) - } - const statsObj = { - id: statsData.id, - name: statsData.name, - cpu: Number(statsData.cpu) || 0, - memory: Number(statsData.memory) || 0, - memoryLimit: Number(statsData.memoryLimit) || 0, - netRxRate: Number(statsData.netRxRate) || 0, - netTxRate: Number(statsData.netTxRate) || 0, - blkReadRate: Number(statsData.blkReadRate) || 0, - blkWriteRate: Number(statsData.blkWriteRate) || 0, - netRxTotal: Number(statsData.netRxTotal) || 0, - netTxTotal: Number(statsData.netTxTotal) || 0, - blkReadTotal: Number(statsData.blkReadTotal) || 0, - blkWriteTotal: Number(statsData.blkWriteTotal) || 0, - ip: statsData.ip, - } - statsCache.set(containerId, { data: statsObj, timestamp: now }) - aggregatedStats.push(statsObj) - recordSample(containerId, { - cpu: statsObj.cpu, - memory: statsObj.memory, - memoryLimit: statsObj.memoryLimit, - netRxRate: statsObj.netRxRate, - netTxRate: statsObj.netTxRate, - blkReadRate: statsObj.blkReadRate, - blkWriteRate: statsObj.blkWriteRate, - }) - } - pruneMissing(Object.keys(containerStats)) - - // Always broadcast when we have containers — even zeros so UI can show 0.00% not "—" - if (aggregatedStats.length > 0) { - peers.broadcast(Pushes.allStats, { type: 'allStats', data: aggregatedStats }) - lastBroadcast = now - } - - for (const [id, cached] of statsCache.entries()) { - if (now - cached.timestamp > STATS_CACHE_TTL * 10) statsCache.delete(id) - } - for (const [id, ts] of containerActivity.entries()) { - if (now - ts > 60000) containerActivity.delete(id) - } - } catch (err) { - const msg = err.message || '' - const dockerDown = - msg.includes('ENOENT') || - msg.includes('ECONNREFUSED') || - msg.includes('docker.sock') - if (dockerDown) { - dockerBackoffUntil = Date.now() + 15000 - if (!dockerDownLogged) { - logger.error('Docker unavailable; stats paused', { error: msg }) - dockerDownLogged = true - } - } else { - logger.error('Stats broadcast failed', { error: msg }) - } - } - }, 1000) +/** + * Tear down all live stats streams and in-memory collection state. + * Used when the last peer disconnects so Docker is not polled idle. + */ +function destroyAllStatsEntries() { + for (const id of Object.keys(containerStats)) { + destroyStatsEntry(id) + } + statsCache.clear() + containerActivity.clear() } -export function stopStatsBroadcast() { +/** + * Pause collection: stop interval and drop Docker stats streams. + * Safe to call when already paused. + */ +export function pauseStatsCollection() { if (intervalHandle) { clearInterval(intervalHandle) intervalHandle = null } - for (const id of Object.keys(containerStats)) { - destroyStatsEntry(id) + destroyAllStatsEntries() + tickInFlight = false + logger.debug('Stats collection paused (no peers)') +} + +async function statsTick() { + if (tickInFlight) return + if (peers.size === 0) { + pauseStatsCollection() + return + } + tickInFlight = true + try { + const now = Date.now() + if (now < dockerBackoffUntil) return + + await collectContainerStats() + dockerDownLogged = false + + // Peer may have left while we were collecting + if (peers.size === 0) { + pauseStatsCollection() + return + } + + if (now - lastBroadcast < STATS_BROADCAST_INTERVAL) return + + const aggregatedStats = [] + for (const [containerId, statsData] of Object.entries(containerStats)) { + const cached = statsCache.get(containerId) + if ( + cached && + now - cached.timestamp < STATS_CACHE_TTL && + !isContainerActive(statsData) + ) { + aggregatedStats.push(cached.data) + continue + } + if (isContainerActive(statsData)) { + containerActivity.set(containerId, now) + } + const statsObj = { + id: statsData.id, + name: statsData.name, + cpu: Number(statsData.cpu) || 0, + memory: Number(statsData.memory) || 0, + memoryLimit: Number(statsData.memoryLimit) || 0, + netRxRate: Number(statsData.netRxRate) || 0, + netTxRate: Number(statsData.netTxRate) || 0, + blkReadRate: Number(statsData.blkReadRate) || 0, + blkWriteRate: Number(statsData.blkWriteRate) || 0, + netRxTotal: Number(statsData.netRxTotal) || 0, + netTxTotal: Number(statsData.netTxTotal) || 0, + blkReadTotal: Number(statsData.blkReadTotal) || 0, + blkWriteTotal: Number(statsData.blkWriteTotal) || 0, + ip: statsData.ip, + } + statsCache.set(containerId, { data: statsObj, timestamp: now }) + aggregatedStats.push(statsObj) + recordSample(containerId, { + cpu: statsObj.cpu, + memory: statsObj.memory, + memoryLimit: statsObj.memoryLimit, + netRxRate: statsObj.netRxRate, + netTxRate: statsObj.netTxRate, + blkReadRate: statsObj.blkReadRate, + blkWriteRate: statsObj.blkWriteRate, + }) + } + pruneMissing(Object.keys(containerStats)) + + // Always broadcast when we have containers — even zeros so UI can show 0.00% not "—" + if (aggregatedStats.length > 0) { + peers.broadcast(Pushes.allStats, { type: 'allStats', data: aggregatedStats }) + lastBroadcast = now + } + + for (const [id, cached] of statsCache.entries()) { + if (now - cached.timestamp > STATS_CACHE_TTL * 10) statsCache.delete(id) + } + for (const [id, ts] of containerActivity.entries()) { + if (now - ts > 60000) containerActivity.delete(id) + } + } catch (err) { + const msg = err.message || '' + const dockerDown = + msg.includes('ENOENT') || + msg.includes('ECONNREFUSED') || + msg.includes('docker.sock') + if (dockerDown) { + dockerBackoffUntil = Date.now() + 15000 + if (!dockerDownLogged) { + logger.error('Docker unavailable; stats paused', { error: msg }) + dockerDownLogged = true + } + } else { + logger.error('Stats broadcast failed', { error: msg }) + } + } finally { + tickInFlight = false } } + +/** + * Resume collection when at least one peer is connected. + * Idempotent; kicks an immediate tick so first client is not waiting a full interval. + */ +export function resumeStatsCollection() { + if (!serviceArmed) return + if (peers.size === 0) return + if (!intervalHandle) { + intervalHandle = setInterval(() => { + statsTick().catch(() => {}) + }, STATS_COLLECT_INTERVAL) + if (typeof intervalHandle.unref === 'function') intervalHandle.unref() + logger.debug('Stats collection resumed', { peers: peers.size }) + } + // Immediate kick so first connected client gets streams ASAP + lastBroadcast = 0 + statsTick().catch(() => {}) +} + +function onPeerCountChange(size, prevSize) { + if (!serviceArmed) return + if (size > 0 && prevSize === 0) { + resumeStatsCollection() + } else if (size === 0 && prevSize > 0) { + pauseStatsCollection() + } +} + +/** + * Arm the stats service. Collection only runs while peers.size > 0. + * Call once at server boot (safe if already started). + */ +export function startStatsBroadcast() { + if (serviceArmed) return + serviceArmed = true + lastBroadcast = 0 + dockerDownLogged = false + dockerBackoffUntil = 0 + + if (!unsubPeers) { + unsubPeers = peers.onChange(onPeerCountChange) + } + + if (peers.size > 0) { + resumeStatsCollection() + } else { + // Explicit idle: no interval, no Docker stats streams + pauseStatsCollection() + logger.debug('Stats service armed (idle until first peer)') + } +} + +/** + * Fully stop the stats service (process shutdown). + */ +export function stopStatsBroadcast() { + serviceArmed = false + if (unsubPeers) { + try { + unsubPeers() + } catch { + // ignore + } + unsubPeers = null + } + pauseStatsCollection() +} + +/** @returns {boolean} whether the collect interval is currently running */ +export function isStatsCollectionActive() { + return Boolean(intervalHandle) +} diff --git a/test/ping-cache.test.js b/test/ping-cache.test.js new file mode 100644 index 0000000..0d3810f --- /dev/null +++ b/test/ping-cache.test.js @@ -0,0 +1,74 @@ +/** + * ping RPC Docker probe cache — second call within TTL must not re-hit Docker. + */ +import test from 'brittle' + +test('probeDockerForPing caches successful probes within TTL', async (t) => { + // Mock dockerode-style client on the module under test via dynamic import is hard; + // exercise the exported helpers by stubbing the docker export. + const dockerMod = await import('../server/services/docker.js') + const system = await import('../server/handlers/system.js') + + system.clearPingDockerCache() + + let pingCalls = 0 + let versionCalls = 0 + const origPing = dockerMod.docker.ping + const origVersion = dockerMod.docker.version + + dockerMod.docker.ping = async () => { + pingCalls += 1 + return {} + } + dockerMod.docker.version = async () => { + versionCalls += 1 + return { ApiVersion: '1.45', Os: 'linux' } + } + + try { + const a = await system.probeDockerForPing() + t.ok(a.ok) + t.is(a.apiVersion, '1.45') + t.is(pingCalls, 1) + t.ok(versionCalls >= 1) + + const pingsBefore = pingCalls + const versionsBefore = versionCalls + const b = await system.probeDockerForPing() + t.ok(b.ok) + t.is(b.apiVersion, '1.45') + t.is(pingCalls, pingsBefore, 'cached: no second docker.ping') + t.is(versionCalls, versionsBefore, 'cached: no second docker.version') + } finally { + dockerMod.docker.ping = origPing + dockerMod.docker.version = origVersion + system.clearPingDockerCache() + } +}) + +test('probeDockerForPing re-probes after failed cache / clear', async (t) => { + const dockerMod = await import('../server/services/docker.js') + const system = await import('../server/handlers/system.js') + system.clearPingDockerCache() + + let pingCalls = 0 + const origPing = dockerMod.docker.ping + const origVersion = dockerMod.docker.version + dockerMod.docker.ping = async () => { + pingCalls += 1 + return {} + } + dockerMod.docker.version = async () => ({ ApiVersion: '1.44', Os: 'linux' }) + + try { + await system.probeDockerForPing() + t.is(pingCalls, 1) + system.clearPingDockerCache() + await system.probeDockerForPing() + t.is(pingCalls, 2, 'after clear, probes again') + } finally { + dockerMod.docker.ping = origPing + dockerMod.docker.version = origVersion + system.clearPingDockerCache() + } +}) diff --git a/test/stats-idle.test.js b/test/stats-idle.test.js new file mode 100644 index 0000000..5699ca4 --- /dev/null +++ b/test/stats-idle.test.js @@ -0,0 +1,90 @@ +/** + * Peer-demand stats: no Docker collection while zero peers. + */ +import test from 'brittle' +import { PeerRegistry } from '../server/core/peer-registry.js' + +test('PeerRegistry onChange fires on add/remove and size transitions', (t) => { + const reg = new PeerRegistry() + /** @type {Array<[number, number]>} */ + const events = [] + const unsub = reg.onChange((size, prev) => events.push([size, prev])) + + const s1 = { id: 'peer-aaa', destroy() {} } + const s2 = { id: 'peer-bbb', destroy() {} } + + reg.add(s1) + t.is(reg.size, 1) + t.alike(events[events.length - 1], [1, 0]) + + reg.add(s2) + t.is(reg.size, 2) + t.alike(events[events.length - 1], [2, 1]) + + reg.remove('peer-aaa') + t.is(reg.size, 1) + t.alike(events[events.length - 1], [1, 2]) + + reg.remove('peer-bbb') + t.is(reg.size, 0) + t.alike(events[events.length - 1], [0, 1]) + + // remove missing id is no-op (no event) + const n = events.length + reg.remove('nope') + t.is(events.length, n) + + unsub() + reg.add(s1) + t.is(events.length, n, 'unsubscribed listener not called') + reg.clear() +}) + +test('PeerRegistry.broadcast is a no-op with zero peers', (t) => { + const reg = new PeerRegistry() + let pushed = 0 + reg.broadcast('push:test', { x: 1 }) + t.is(pushed, 0) + + reg.add({ + id: 'p1', + push() { + pushed += 1 + }, + destroy() {}, + }) + reg.broadcast('push:test', { x: 1 }) + t.is(pushed, 1) + reg.clear() +}) + +test('stats service: idle when no peers; active when peer present', async (t) => { + // Isolate module state by dynamic import after resetting — use public API only. + const stats = await import('../server/services/stats.js') + const { peers } = await import('../server/core/peer-registry.js') + + // Ensure clean slate from any prior test importing peers + peers.clear() + stats.stopStatsBroadcast() + + stats.startStatsBroadcast() + t.is(stats.isStatsCollectionActive(), false, 'no collect interval with 0 peers') + + const fake = { + id: 'test-peer-stats-idle', + role: 'viewer', + push() {}, + destroy() {}, + } + peers.add(fake) + // resume is async-kicked; give microtask + timer a tick + await new Promise((r) => setTimeout(r, 50)) + t.is(stats.isStatsCollectionActive(), true, 'collect interval after first peer') + + peers.remove(fake.id) + await new Promise((r) => setTimeout(r, 20)) + t.is(stats.isStatsCollectionActive(), false, 'collect interval stopped after last peer') + + stats.stopStatsBroadcast() + peers.clear() +}) diff --git a/ui/ops-app.js b/ui/ops-app.js index f021cbd..9c30ec1 100644 --- a/ui/ops-app.js +++ b/ui/ops-app.js @@ -586,6 +586,10 @@ export function saveSettings(partial) { /** @type {ReturnType|null} */ let listRefreshTimer = null +/** Seconds last applied to auto-refresh (for visibility resume) */ +let listRefreshSeconds = 0 +/** Whether visibility listener is attached */ +let listRefreshVisibilityWired = false export function applySettings(s = loadSettings()) { document.body.dataset.density = s.density || 'comfortable' @@ -673,8 +677,49 @@ export function shouldShowFirstConnectTip() { return loadSettings().showFirstConnectTip !== false } +/** + * One auto-refresh tick for the active view (shared by interval + visibility resume). + */ +function runListAutoRefreshTick() { + // Do not hammer Docker while the window/tab is backgrounded + if (typeof document !== 'undefined' && document.hidden) return + if (!manager.active?.connected) return + const view = typeof window !== 'undefined' ? window.currentView : null + const send = typeof window !== 'undefined' ? window.sendCommand : null + if (!send) return + // silent: background polls must not spam "REQUEST_ERROR: Request failed" + const quiet = { silent: true } + if (view === 'containers' || view === 'dashboard') send('listContainers', {}, quiet) + else if (view === 'images') send('listImages', {}, quiet) + else if (view === 'networks') send('listNetworks', {}, quiet) + else if (view === 'volumes') send('listVolumes', {}, quiet) + else if (view === 'stacks') send('listStacks', {}, quiet) + else if (view === 'host') loadHostView({ silent: true }) + else if (view === 'tunnels') loadTunnelsView({ silent: true }) + else if (view === 'events') loadEventsView({ silent: true }) + else if (view === 'swarm') loadSwarmView({ silent: true }) + try { + window.peardockUx?.stampOnPoll?.() + } catch { + // ignore + } +} + +function wireListRefreshVisibility() { + if (listRefreshVisibilityWired || typeof document === 'undefined') return + listRefreshVisibilityWired = true + document.addEventListener('visibilitychange', () => { + if (document.hidden) return + // On focus: one catch-up tick if auto-refresh is enabled + if (listRefreshSeconds >= 1 && manager.active?.connected) { + runListAutoRefreshTick() + } + }) +} + /** * Poll the active resource list when Settings auto-refresh > 0. + * Skips ticks while document.hidden to avoid idle Docker socket load. * @param {number} [seconds] */ export function startListAutoRefresh(seconds) { @@ -683,28 +728,11 @@ export function startListAutoRefresh(seconds) { listRefreshTimer = null } const sec = Number(seconds ?? loadSettings().refreshSeconds ?? 0) + listRefreshSeconds = sec > 0 ? sec : 0 if (!sec || sec < 1) return + wireListRefreshVisibility() listRefreshTimer = setInterval(() => { - if (!manager.active?.connected) return - const view = typeof window !== 'undefined' ? window.currentView : null - const send = typeof window !== 'undefined' ? window.sendCommand : null - if (!send) return - // silent: background polls must not spam "REQUEST_ERROR: Request failed" - const quiet = { silent: true } - if (view === 'containers' || view === 'dashboard') send('listContainers', {}, quiet) - else if (view === 'images') send('listImages', {}, quiet) - else if (view === 'networks') send('listNetworks', {}, quiet) - else if (view === 'volumes') send('listVolumes', {}, quiet) - else if (view === 'stacks') send('listStacks', {}, quiet) - else if (view === 'host') loadHostView({ silent: true }) - else if (view === 'tunnels') loadTunnelsView({ silent: true }) - else if (view === 'events') loadEventsView({ silent: true }) - else if (view === 'swarm') loadSwarmView({ silent: true }) - try { - window.peardockUx?.stampOnPoll?.() - } catch { - // ignore - } + runListAutoRefreshTick() }, sec * 1000) }