This commit is contained in:
@@ -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,12 +2557,76 @@ 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) {
|
||||||
volumesStoreSubscription = volumesStore.subscribe((volumes) => {
|
volumesStoreSubscription = volumesStore.subscribe((volumes) => {
|
||||||
@@ -2628,22 +2672,27 @@ function loadDashboard() {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.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');
|
sendCommand('getSystemInfo');
|
||||||
|
}
|
||||||
|
if (!isDashboardCacheFresh('systemDf')) {
|
||||||
sendCommand('getSystemDf');
|
sendCommand('getSystemDf');
|
||||||
|
}
|
||||||
// Load container stats for counts
|
if (!isDashboardCacheFresh('containers')) {
|
||||||
sendCommand('listContainers');
|
sendCommand('listContainers');
|
||||||
|
}
|
||||||
// Load images count
|
if (!isDashboardCacheFresh('images')) {
|
||||||
sendCommand('listImages');
|
sendCommand('listImages');
|
||||||
|
}
|
||||||
// Load networks count
|
if (!isDashboardCacheFresh('networks')) {
|
||||||
sendCommand('listNetworks');
|
sendCommand('listNetworks');
|
||||||
|
}
|
||||||
// Load volumes
|
if (!isDashboardCacheFresh('volumes')) {
|
||||||
sendCommand('listVolumes');
|
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;
|
connection.latency = peer.latency ?? connection.latency;
|
||||||
}
|
connection.healthStatus = peer.healthStatus || connection.healthStatus || 'healthy';
|
||||||
if (entry && entry.peer !== peer) entry.peer = peer;
|
connection.lastHealthCheck = peer.lastHealthCheck || connection.lastHealthCheck || Date.now();
|
||||||
try {
|
|
||||||
const ms = await peer.ping();
|
|
||||||
entry.latency = ms;
|
|
||||||
entry.lastHealthCheck = Date.now();
|
|
||||||
entry.healthStatus = ms < 5000 ? 'healthy' : 'slow';
|
|
||||||
updateConnectionStatus(topicId, true);
|
updateConnectionStatus(topicId, true);
|
||||||
updateConnectionDisplay(topicId);
|
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
|
// 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);
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+79
-10
@@ -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.
|
||||||
|
* 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 dockerOk = false
|
||||||
let apiVersion = null
|
let apiVersion = pingDockerCache?.apiVersion ?? null
|
||||||
let osType = null
|
let osType = pingDockerCache?.os ?? null
|
||||||
let error = null
|
let error = null
|
||||||
|
|
||||||
try {
|
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(),
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
+123
-15
@@ -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,13 +345,39 @@ 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)) {
|
||||||
|
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 {
|
try {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now < dockerBackoffUntil) return
|
if (now < dockerBackoffUntil) return
|
||||||
@@ -346,8 +385,13 @@ export function startStatsBroadcast() {
|
|||||||
await collectContainerStats()
|
await collectContainerStats()
|
||||||
dockerDownLogged = false
|
dockerDownLogged = false
|
||||||
|
|
||||||
|
// Peer may have left while we were collecting
|
||||||
|
if (peers.size === 0) {
|
||||||
|
pauseStatsCollection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (now - lastBroadcast < STATS_BROADCAST_INTERVAL) return
|
if (now - lastBroadcast < STATS_BROADCAST_INTERVAL) return
|
||||||
if (peers.size === 0) return
|
|
||||||
|
|
||||||
const aggregatedStats = []
|
const aggregatedStats = []
|
||||||
for (const [containerId, statsData] of Object.entries(containerStats)) {
|
for (const [containerId, statsData] of Object.entries(containerStats)) {
|
||||||
@@ -420,16 +464,80 @@ export function startStatsBroadcast() {
|
|||||||
} else {
|
} else {
|
||||||
logger.error('Stats broadcast failed', { error: msg })
|
logger.error('Stats broadcast failed', { error: msg })
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
tickInFlight = false
|
||||||
}
|
}
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export function stopStatsBroadcast() {
|
||||||
if (intervalHandle) {
|
serviceArmed = false
|
||||||
clearInterval(intervalHandle)
|
if (unsubPeers) {
|
||||||
intervalHandle = null
|
try {
|
||||||
|
unsubPeers()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
for (const id of Object.keys(containerStats)) {
|
unsubPeers = null
|
||||||
destroyStatsEntry(id)
|
|
||||||
}
|
}
|
||||||
|
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} */
|
/** @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'
|
||||||
@@ -674,17 +678,11 @@ export function shouldShowFirstConnectTip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll the active resource list when Settings auto-refresh > 0.
|
* One auto-refresh tick for the active view (shared by interval + visibility resume).
|
||||||
* @param {number} [seconds]
|
|
||||||
*/
|
*/
|
||||||
export function startListAutoRefresh(seconds) {
|
function runListAutoRefreshTick() {
|
||||||
if (listRefreshTimer) {
|
// Do not hammer Docker while the window/tab is backgrounded
|
||||||
clearInterval(listRefreshTimer)
|
if (typeof document !== 'undefined' && document.hidden) return
|
||||||
listRefreshTimer = null
|
|
||||||
}
|
|
||||||
const sec = Number(seconds ?? loadSettings().refreshSeconds ?? 0)
|
|
||||||
if (!sec || sec < 1) return
|
|
||||||
listRefreshTimer = setInterval(() => {
|
|
||||||
if (!manager.active?.connected) return
|
if (!manager.active?.connected) return
|
||||||
const view = typeof window !== 'undefined' ? window.currentView : null
|
const view = typeof window !== 'undefined' ? window.currentView : null
|
||||||
const send = typeof window !== 'undefined' ? window.sendCommand : null
|
const send = typeof window !== 'undefined' ? window.sendCommand : null
|
||||||
@@ -705,6 +703,36 @@ export function startListAutoRefresh(seconds) {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// 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)
|
}, sec * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user