Optimize Idle CPU/Dash
Release rolling / release (push) Successful in 8m21s

This commit is contained in:
Raven Scott
2026-08-04 21:04:55 -04:00
parent 450b54ca55
commit ab6cea96c7
9 changed files with 690 additions and 214 deletions
+126 -74
View File
@@ -107,11 +107,7 @@ function bindLivePeer(conn) {
if (conn.capability) entry.capability = conn.capability; if (conn.capability) entry.capability = conn.capability;
updateConnectionStatus(topicId, true); updateConnectionStatus(topicId, true);
updateConnectionDisplay(topicId); updateConnectionDisplay(topicId);
// Restart per-peer health UI loop (clears itself if peer drops) // Sync health UI from live peer (manager health loop owns pings — no second interval)
if (entry.healthCheckInterval) {
clearInterval(entry.healthCheckInterval);
entry.healthCheckInterval = null;
}
startHealthMonitoring(topicId); startHealthMonitoring(topicId);
} }
@@ -407,14 +403,22 @@ const volumesStore = {
// Expose volumes store to window for use by other modules // Expose volumes store to window for use by other modules
window.volumesStore = volumesStore; window.volumesStore = volumesStore;
let lastStatsUpdate = Date.now(); 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() { function stopStatsInterval() {
if (statsInterval) { if (statsInterval) {
clearInterval(statsInterval); clearInterval(statsInterval);
statsInterval = null; 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 // 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 smoothedStats = {}; // Container-specific smoothing storage
const historicalStats = {}; // Container-specific historical stats for charts const historicalStats = {}; // Container-specific historical stats for charts
const MAX_HISTORY_POINTS = 900; // ~30m at 2s broadcast interval const MAX_HISTORY_POINTS = 900; // ~30m at 2s broadcast interval
@@ -2577,11 +2557,75 @@ document.addEventListener('DOMContentLoaded', () => {
// Expose to window for onclick handlers // Expose to window for onclick handlers
window.navigateToView = navigateToView; 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<string, number> }} */
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 // Dashboard Functions
function loadDashboard() { function loadDashboard() {
if (!hasActiveConnection() && !window.activePeer) { if (!hasActiveConnection() && !window.activePeer) {
return; return;
} }
const peerId = dashboardCachePeerId();
if (peerId && dashboardRpcCache.peerId !== peerId) {
dashboardRpcCache.peerId = peerId;
dashboardRpcCache.at = {};
}
// Set up volumes subscription early to catch broadcasts // Set up volumes subscription early to catch broadcasts
if (!volumesStoreSubscription) { if (!volumesStoreSubscription) {
@@ -2627,22 +2671,27 @@ function loadDashboard() {
} }
}) })
.catch(() => {}); .catch(() => {});
// Load system info // Only re-fetch slices that are stale — same data when fresh, less Docker socket load.
sendCommand('getSystemInfo'); // When fresh, rely on event pushes + auto-refresh for updates.
sendCommand('getSystemDf'); if (!isDashboardCacheFresh('systemInfo')) {
sendCommand('getSystemInfo');
// Load container stats for counts }
sendCommand('listContainers'); if (!isDashboardCacheFresh('systemDf')) {
sendCommand('getSystemDf');
// Load images count }
sendCommand('listImages'); if (!isDashboardCacheFresh('containers')) {
sendCommand('listContainers');
// Load networks count }
sendCommand('listNetworks'); if (!isDashboardCacheFresh('images')) {
sendCommand('listImages');
// Load volumes }
sendCommand('listVolumes'); if (!isDashboardCacheFresh('networks')) {
sendCommand('listNetworks');
}
if (!isDashboardCacheFresh('volumes')) {
sendCommand('listVolumes');
}
} }
window.loadDashboard = loadDashboard; window.loadDashboard = loadDashboard;
@@ -2662,6 +2711,7 @@ function formatBytes(bytes) {
function refreshSystemDf() { function refreshSystemDf() {
if (!hasActiveConnection()) return; if (!hasActiveConnection()) return;
invalidateDashboardCache('systemDf');
sendCommand('getSystemDf'); sendCommand('getSystemDf');
} }
window.refreshSystemDf = refreshSystemDf; window.refreshSystemDf = refreshSystemDf;
@@ -9773,6 +9823,7 @@ function handleRpcMessage(response, conn) {
: null; : null;
if (rows) { if (rows) {
applyContainerSnapshot(rows, topicId, { source: 'rpc' }); applyContainerSnapshot(rows, topicId, { source: 'rpc' });
touchDashboardCache('containers');
if (currentView === 'dashboard') { if (currentView === 'dashboard') {
updateDashboardStats(containerFilterState.allContainers, null, null); updateDashboardStats(containerFilterState.allContainers, null, null);
} }
@@ -9869,11 +9920,13 @@ function handleRpcMessage(response, conn) {
case 'systemInfo': case 'systemInfo':
console.log('[INFO] Handling system information...'); console.log('[INFO] Handling system information...');
updateSystemInfo(response.data); updateSystemInfo(response.data);
touchDashboardCache('systemInfo');
break; break;
case 'images': case 'images':
console.log('[INFO] Handling images list...'); console.log('[INFO] Handling images list...');
renderImages(response.data); renderImages(response.data);
touchDashboardCache('images');
// Update dashboard stats if on dashboard view // Update dashboard stats if on dashboard view
if (currentView === 'dashboard') { if (currentView === 'dashboard') {
updateDashboardStats(null, response.data, null); updateDashboardStats(null, response.data, null);
@@ -9883,6 +9936,7 @@ function handleRpcMessage(response, conn) {
case 'networks': case 'networks':
console.log('[INFO] Handling networks list...'); console.log('[INFO] Handling networks list...');
renderNetworks(response.data); renderNetworks(response.data);
touchDashboardCache('networks');
// Update dashboard stats if on dashboard view // Update dashboard stats if on dashboard view
if (currentView === 'dashboard') { if (currentView === 'dashboard') {
updateDashboardStats(null, null, response.data); updateDashboardStats(null, null, response.data);
@@ -9903,6 +9957,7 @@ function handleRpcMessage(response, conn) {
if (volumesToRender !== null) { if (volumesToRender !== null) {
// Always update store first (this will trigger subscriptions) // Always update store first (this will trigger subscriptions)
volumesStore.set(volumesToRender); volumesStore.set(volumesToRender);
touchDashboardCache('volumes');
// Always render if on volumes view to ensure UI is updated // Always render if on volumes view to ensure UI is updated
if (currentView === 'volumes') { if (currentView === 'volumes') {
renderVolumes(volumesToRender); renderVolumes(volumesToRender);
@@ -9945,6 +10000,13 @@ function handleRpcMessage(response, conn) {
} }
case 'dockerEvent': { 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) { if (window.handleDockerEvent) {
window.handleDockerEvent(response.data); window.handleDockerEvent(response.data);
} }
@@ -10015,6 +10077,7 @@ function handleRpcMessage(response, conn) {
case 'systemDf': case 'systemDf':
renderSystemDf(response.data); renderSystemDf(response.data);
touchDashboardCache('systemDf');
break; break;
case 'containerTop': case 'containerTop':
@@ -11248,8 +11311,12 @@ function updateConnectionDisplay(topicId) {
: String(fullKey); : 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) { function startHealthMonitoring(topicId) {
const connection = connections[topicId]; const connection = connections[topicId];
if (!connection) return; if (!connection) return;
@@ -11258,36 +11325,20 @@ function startHealthMonitoring(topicId) {
connection.healthCheckInterval = null; connection.healthCheckInterval = null;
} }
const healthCheckInterval = setInterval(async () => { const peer =
const entry = connections[topicId]; connection.peer?.connected
// Prefer manager's live socket if UI map is briefly stale after reconnect ? connection.peer
const peer = entry?.peer?.connected
? entry.peer
: manager.connections.get(topicId)?.connected : manager.connections.get(topicId)?.connected
? manager.connections.get(topicId) ? manager.connections.get(topicId)
: null; : null;
if (!peer) { if (!peer) return;
clearInterval(healthCheckInterval); if (connection.peer !== peer) connection.peer = peer;
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);
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 // Switch between connections
@@ -11336,6 +11387,7 @@ function switchConnection(topicId, opts = {}) {
// Drop previous host's containers immediately so the table never mixes peers // Drop previous host's containers immediately so the table never mixes peers
resetContainerList(); resetContainerList();
containerStore.topicId = manager.active?.id || topicId || ''; containerStore.topicId = manager.active?.id || topicId || '';
invalidateDashboardCache('all');
console.log(`[INFO] Switched to connection: ${topicId}`); console.log(`[INFO] Switched to connection: ${topicId}`);
startStatsInterval(); startStatsInterval();
sendCommand(Methods.listContainers); sendCommand(Methods.listContainers);
+38 -1
View File
@@ -9,20 +9,54 @@ export class PeerRegistry {
constructor() { constructor() {
/** @type {Map<string, import('../rpc/session.js').PeerSession>} */ /** @type {Map<string, import('../rpc/session.js').PeerSession>} */
this.sessions = new 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 * @param {import('../rpc/session.js').PeerSession} session
*/ */
add(session) { add(session) {
const prev = this.sessions.size
this.sessions.set(session.id, session) this.sessions.set(session.id, session)
this._emitChange(prev)
} }
/** /**
* @param {string} id * @param {string} id
*/ */
remove(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 * @param {unknown} payload
*/ */
broadcast(method, payload) { broadcast(method, payload) {
if (this.sessions.size === 0) return
for (const session of this.sessions.values()) { for (const session of this.sessions.values()) {
try { try {
session.push(method, payload) session.push(method, payload)
@@ -60,6 +95,7 @@ export class PeerRegistry {
} }
clear() { clear() {
const prev = this.sessions.size
for (const session of this.sessions.values()) { for (const session of this.sessions.values()) {
try { try {
session.destroy() session.destroy()
@@ -68,6 +104,7 @@ export class PeerRegistry {
} }
} }
this.sessions.clear() this.sessions.clear()
this._emitChange(prev)
} }
} }
+82 -13
View File
@@ -20,20 +20,89 @@ import logger from '../utils/logger.js'
/** @type {Map<string, { username: string, serveraddress?: string }>} */ /** @type {Map<string, { username: string, serveraddress?: string }>} */
const registryAuth = new Map() const registryAuth = new Map()
export function registerSystemHandlers(session) { /**
session.respond('ping', async () => { * Cached Docker probe for high-frequency ping RPCs.
let dockerOk = false * Clients poll health every ~10s; version() is heavier than needed every tick.
let apiVersion = null * TTL keeps degraded detection responsive without hammering the socket.
let osType = null */
let error = null const PING_DOCKER_CACHE_TTL_MS = 10_000
try { /** @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() const version = await docker.version()
dockerOk = true dockerOk = true
apiVersion = version.ApiVersion || version.apiVersion || null apiVersion = version.ApiVersion || version.apiVersion || null
osType = version.Os || version.os || 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 { return {
success: true, success: true,
pong: Date.now(), pong: Date.now(),
@@ -41,10 +110,10 @@ export function registerSystemHandlers(session) {
protocolVersion: PROTOCOL_VERSION, protocolVersion: PROTOCOL_VERSION,
role: session.role, role: session.role,
docker: { docker: {
ok: dockerOk, ok: dockerProbe.ok,
apiVersion, apiVersion: dockerProbe.apiVersion,
os: osType, os: dockerProbe.os,
error, error: dockerProbe.error,
}, },
features: { features: {
swarm: isSwarmEnabled(), swarm: isSwarmEnabled(),
+11 -4
View File
@@ -1073,10 +1073,18 @@ async function pollHealth() {
return // skip further polls if docker is down 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 // Container health
try { 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] const health = c.Status?.match(/\((healthy|unhealthy|health: starting)\)/i)?.[1]
|| c.State // running/exited — Health may be in inspect only || c.State // running/exited — Health may be in inspect only
// Prefer Health from inspect-lite if present // Prefer Health from inspect-lite if present
@@ -1098,10 +1106,9 @@ async function pollHealth() {
// Stack / compose project health (group by com.docker.compose.project) // Stack / compose project health (group by com.docker.compose.project)
try { try {
const containers = await docker.listContainers({ all: true })
/** @type {Map<string, { name: string, running: number, total: number, unhealthy: number }>} */ /** @type {Map<string, { name: string, running: number, total: number, unhealthy: number }>} */
const stacks = new Map() const stacks = new Map()
for (const c of containers || []) { for (const c of containers) {
const project = const project =
c.Labels?.['com.docker.compose.project'] || c.Labels?.['com.docker.compose.project'] ||
c.Labels?.['com.docker.stack.namespace'] c.Labels?.['com.docker.stack.namespace']
+18 -7
View File
@@ -39,9 +39,11 @@ const CONTAINER_LIST_ACTIONS = new Set([
]) ])
function scheduleContainerListBroadcast() { function scheduleContainerListBroadcast() {
if (peers.size === 0) return
if (containerListBroadcastTimer) return if (containerListBroadcastTimer) return
containerListBroadcastTimer = setTimeout(() => { containerListBroadcastTimer = setTimeout(() => {
containerListBroadcastTimer = null containerListBroadcastTimer = null
if (peers.size === 0) return
broadcastContainers().catch((err) => { broadcastContainers().catch((err) => {
logger.debug('container list broadcast failed', { error: err.message }) logger.debug('container list broadcast failed', { error: err.message })
}) })
@@ -49,9 +51,11 @@ function scheduleContainerListBroadcast() {
} }
function scheduleImageListBroadcast() { function scheduleImageListBroadcast() {
if (peers.size === 0) return
if (imageListBroadcastTimer) return if (imageListBroadcastTimer) return
imageListBroadcastTimer = setTimeout(() => { imageListBroadcastTimer = setTimeout(() => {
imageListBroadcastTimer = null imageListBroadcastTimer = null
if (peers.size === 0) return
broadcastImages().catch((err) => { broadcastImages().catch((err) => {
logger.debug('image list broadcast failed', { error: err.message }) logger.debug('image list broadcast failed', { error: err.message })
}) })
@@ -95,18 +99,13 @@ async function openEventStream() {
} }
if (event.status === 'undefined') continue if (event.status === 'undefined') continue
logger.info('Docker event', { logger.debug('Docker event', {
status: event.status, status: event.status,
id: event.id, id: event.id,
type: event.Type, type: event.Type,
action: event.Action, action: event.Action,
}) })
peers.broadcast(Pushes.dockerEvent, {
type: 'dockerEvent',
data: event,
})
// Server-side alerting (webhooks) — independent of connected clients // Server-side alerting (webhooks) — independent of connected clients
try { try {
onDockerEvent(event) onDockerEvent(event)
@@ -114,6 +113,14 @@ async function openEventStream() {
logger.debug('alerts onDockerEvent error', { error: e?.message }) 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 — // Do not rebroadcast the full list on every exec_*/health_status event —
// that thrashed clients (rows flicker / disappear on each refresh). // that thrashed clients (rows flicker / disappear on each refresh).
if (event.Type === 'container') { if (event.Type === 'container') {
@@ -127,7 +134,11 @@ async function openEventStream() {
scheduleImageListBroadcast() 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 volumesResult = await docker.listVolumes()
const volumesList = extractVolumesList(volumesResult) const volumesList = extractVolumesList(volumesResult)
peers.broadcast(Pushes.volumes, { peers.broadcast(Pushes.volumes, {
+203 -95
View File
@@ -9,10 +9,14 @@ import { docker, extractIpAddress } from './docker.js'
import { peers } from '../core/peer-registry.js' import { peers } from '../core/peer-registry.js'
import { Pushes } from '../../shared/protocol.js' import { Pushes } from '../../shared/protocol.js'
import { recordSample, pruneMissing } from './stats-history.js' import { recordSample, pruneMissing } from './stats-history.js'
import { CONFIG } from '../../config.js'
import logger from '../utils/logger.js' import logger from '../utils/logger.js'
const STATS_CACHE_TTL = 1000 const STATS_CACHE_TTL = CONFIG.STATS?.CACHE_TTL_MS ?? 1000
const STATS_BROADCAST_INTERVAL = 2000 /** 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<string, object>} */ /** @type {Record<string, object>} */
const containerStats = {} const containerStats = {}
@@ -20,6 +24,15 @@ const statsCache = new Map()
const containerActivity = new Map() const containerActivity = new Map()
let intervalHandle = null 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`). * Docker Engine CPU % (same idea as `docker stats`).
@@ -332,104 +345,199 @@ async function collectContainerStats() {
} }
} }
export function startStatsBroadcast() { /**
if (intervalHandle) return * Tear down all live stats streams and in-memory collection state.
let lastBroadcast = 0 * Used when the last peer disconnects so Docker is not polled idle.
let dockerDownLogged = false */
let dockerBackoffUntil = 0 function destroyAllStatsEntries() {
for (const id of Object.keys(containerStats)) {
intervalHandle = setInterval(async () => { destroyStatsEntry(id)
try { }
const now = Date.now() statsCache.clear()
if (now < dockerBackoffUntil) return containerActivity.clear()
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)
} }
export function stopStatsBroadcast() { /**
* Pause collection: stop interval and drop Docker stats streams.
* Safe to call when already paused.
*/
export function pauseStatsCollection() {
if (intervalHandle) { if (intervalHandle) {
clearInterval(intervalHandle) clearInterval(intervalHandle)
intervalHandle = null intervalHandle = null
} }
for (const id of Object.keys(containerStats)) { destroyAllStatsEntries()
destroyStatsEntry(id) 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)
}
+74
View File
@@ -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()
}
})
+90
View File
@@ -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()
})
+48 -20
View File
@@ -586,6 +586,10 @@ export function saveSettings(partial) {
/** @type {ReturnType<typeof setInterval>|null} */ /** @type {ReturnType<typeof setInterval>|null} */
let listRefreshTimer = 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()) { export function applySettings(s = loadSettings()) {
document.body.dataset.density = s.density || 'comfortable' document.body.dataset.density = s.density || 'comfortable'
@@ -673,8 +677,49 @@ export function shouldShowFirstConnectTip() {
return loadSettings().showFirstConnectTip !== false 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. * Poll the active resource list when Settings auto-refresh > 0.
* Skips ticks while document.hidden to avoid idle Docker socket load.
* @param {number} [seconds] * @param {number} [seconds]
*/ */
export function startListAutoRefresh(seconds) { export function startListAutoRefresh(seconds) {
@@ -683,28 +728,11 @@ export function startListAutoRefresh(seconds) {
listRefreshTimer = null listRefreshTimer = null
} }
const sec = Number(seconds ?? loadSettings().refreshSeconds ?? 0) const sec = Number(seconds ?? loadSettings().refreshSeconds ?? 0)
listRefreshSeconds = sec > 0 ? sec : 0
if (!sec || sec < 1) return if (!sec || sec < 1) return
wireListRefreshVisibility()
listRefreshTimer = setInterval(() => { listRefreshTimer = setInterval(() => {
if (!manager.active?.connected) return runListAutoRefreshTick()
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
}
}, sec * 1000) }, sec * 1000)
} }