This commit is contained in:
@@ -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,12 +2557,76 @@ 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<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
|
||||
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) {
|
||||
volumesStoreSubscription = volumesStore.subscribe((volumes) => {
|
||||
@@ -2628,21 +2672,26 @@ function loadDashboard() {
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Load system info
|
||||
// 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');
|
||||
|
||||
// Load container stats for counts
|
||||
}
|
||||
if (!isDashboardCacheFresh('containers')) {
|
||||
sendCommand('listContainers');
|
||||
|
||||
// Load images count
|
||||
}
|
||||
if (!isDashboardCacheFresh('images')) {
|
||||
sendCommand('listImages');
|
||||
|
||||
// Load networks count
|
||||
}
|
||||
if (!isDashboardCacheFresh('networks')) {
|
||||
sendCommand('listNetworks');
|
||||
|
||||
// Load volumes
|
||||
}
|
||||
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';
|
||||
if (!peer) return;
|
||||
if (connection.peer !== peer) connection.peer = peer;
|
||||
|
||||
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);
|
||||
} 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -9,20 +9,54 @@ export class PeerRegistry {
|
||||
constructor() {
|
||||
/** @type {Map<string, import('../rpc/session.js').PeerSession>} */
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+79
-10
@@ -20,20 +20,89 @@ import logger from '../utils/logger.js'
|
||||
/** @type {Map<string, { username: string, serveraddress?: string }>} */
|
||||
const registryAuth = new Map()
|
||||
|
||||
export function registerSystemHandlers(session) {
|
||||
session.respond('ping', async () => {
|
||||
/**
|
||||
* 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 = null
|
||||
let osType = null
|
||||
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(),
|
||||
|
||||
@@ -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<string, { name: string, running: number, total: number, unhealthy: number }>} */
|
||||
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']
|
||||
|
||||
@@ -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, {
|
||||
|
||||
+124
-16
@@ -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<string, object>} */
|
||||
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,13 +345,39 @@ async function collectContainerStats() {
|
||||
}
|
||||
}
|
||||
|
||||
export function startStatsBroadcast() {
|
||||
if (intervalHandle) return
|
||||
let lastBroadcast = 0
|
||||
let dockerDownLogged = false
|
||||
let dockerBackoffUntil = 0
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
|
||||
intervalHandle = setInterval(async () => {
|
||||
/**
|
||||
* Pause collection: stop interval and drop Docker stats streams.
|
||||
* Safe to call when already paused.
|
||||
*/
|
||||
export function pauseStatsCollection() {
|
||||
if (intervalHandle) {
|
||||
clearInterval(intervalHandle)
|
||||
intervalHandle = null
|
||||
}
|
||||
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
|
||||
@@ -346,8 +385,13 @@ export function startStatsBroadcast() {
|
||||
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
|
||||
if (peers.size === 0) return
|
||||
|
||||
const aggregatedStats = []
|
||||
for (const [containerId, statsData] of Object.entries(containerStats)) {
|
||||
@@ -420,16 +464,80 @@ export function startStatsBroadcast() {
|
||||
} else {
|
||||
logger.error('Stats broadcast failed', { error: msg })
|
||||
}
|
||||
} finally {
|
||||
tickInFlight = false
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
export function stopStatsBroadcast() {
|
||||
if (intervalHandle) {
|
||||
clearInterval(intervalHandle)
|
||||
intervalHandle = null
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
for (const id of Object.keys(containerStats)) {
|
||||
destroyStatsEntry(id)
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
+38
-10
@@ -586,6 +586,10 @@ export function saveSettings(partial) {
|
||||
|
||||
/** @type {ReturnType<typeof setInterval>|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'
|
||||
@@ -674,17 +678,11 @@ export function shouldShowFirstConnectTip() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the active resource list when Settings auto-refresh > 0.
|
||||
* @param {number} [seconds]
|
||||
* One auto-refresh tick for the active view (shared by interval + visibility resume).
|
||||
*/
|
||||
export function startListAutoRefresh(seconds) {
|
||||
if (listRefreshTimer) {
|
||||
clearInterval(listRefreshTimer)
|
||||
listRefreshTimer = null
|
||||
}
|
||||
const sec = Number(seconds ?? loadSettings().refreshSeconds ?? 0)
|
||||
if (!sec || sec < 1) return
|
||||
listRefreshTimer = setInterval(() => {
|
||||
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
|
||||
@@ -705,6 +703,36 @@ export function startListAutoRefresh(seconds) {
|
||||
} 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) {
|
||||
if (listRefreshTimer) {
|
||||
clearInterval(listRefreshTimer)
|
||||
listRefreshTimer = null
|
||||
}
|
||||
const sec = Number(seconds ?? loadSettings().refreshSeconds ?? 0)
|
||||
listRefreshSeconds = sec > 0 ? sec : 0
|
||||
if (!sec || sec < 1) return
|
||||
wireListRefreshVisibility()
|
||||
listRefreshTimer = setInterval(() => {
|
||||
runListAutoRefreshTick()
|
||||
}, sec * 1000)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user