Bring Back Realtime Table Stats
Release rolling / release (push) Successful in 7m55s

This commit is contained in:
Raven Scott
2026-08-04 22:37:41 -04:00
parent c5015a66e8
commit 637c386e33
12 changed files with 689 additions and 636 deletions
+91
View File
@@ -125,6 +125,8 @@ manager.on('disconnect', (conn) => {
} }
if (!manager.active?.connected) { if (!manager.active?.connected) {
updateHealthBadge(null); updateHealthBadge(null);
lastStatsInterestSent = null;
lastStatsInterestPeerId = null;
} }
refreshFleetIfVisible(); refreshFleetIfVisible();
if (isBootRestoring) return; if (isBootRestoring) return;
@@ -133,6 +135,8 @@ manager.on('disconnect', (conn) => {
if (!hasActiveConnection()) { if (!hasActiveConnection()) {
resetContainerList(); resetContainerList();
stopStatsInterval(); stopStatsInterval();
lastStatsInterestSent = null;
lastStatsInterestPeerId = null;
if (typeof manager.isReconnecting === 'function' && manager.isReconnecting()) { if (typeof manager.isReconnecting === 'function' && manager.isReconnecting()) {
// reconnecting banner is driven by 'reconnecting' events // reconnecting banner is driven by 'reconnecting' events
return; return;
@@ -160,6 +164,26 @@ manager.on('connect', (conn) => {
{ latency: conn.latency, docker: conn.dockerHealth, status: conn.healthStatus }, { latency: conn.latency, docker: conn.dockerHealth, status: conn.healthStatus },
conn conn
); );
// Re-declare view interest after (re)connect so stats streams resume if needed
lastStatsInterestSent = null;
lastStatsInterestPeerId = null;
try {
syncStatsInterest({ force: true });
} catch {
// ignore
}
}
});
manager.on('active', (conn) => {
// Switching fleet peer: re-send interest to the new active host
lastStatsInterestSent = null;
lastStatsInterestPeerId = null;
if (conn?.connected) {
try {
syncStatsInterest({ force: true });
} catch {
// ignore
}
} }
}); });
/** Throttle reconnect notices (standard mode only) */ /** Throttle reconnect notices (standard mode only) */
@@ -419,6 +443,66 @@ function startStatsInterval() {
stopStatsInterval(); stopStatsInterval();
} }
/**
* Views that show live per-container CPU/memory (table or dashboard KPIs).
* Server only opens Docker stats streams while 1 peer reports interest.
*/
const STATS_INTEREST_VIEWS = new Set(['containers', 'dashboard', 'container-details']);
/** @type {boolean|null} last interest sent for active peer (avoid spam) */
let lastStatsInterestSent = null;
/** @type {string|null} peer id we last told */
let lastStatsInterestPeerId = null;
/**
* Tell the active peer whether this UI needs live fleet stats.
* Call on view change and after connect. Leaving Containers/Dashboard tears
* down server-side Docker stats streams for this client.
* @param {{ force?: boolean }} [opts]
*/
function syncStatsInterest(opts = {}) {
const force = opts.force === true;
const conn = manager.active;
if (!conn?.connected) {
lastStatsInterestSent = null;
lastStatsInterestPeerId = null;
return;
}
const view =
typeof currentView === 'string'
? currentView
: typeof window !== 'undefined' && typeof window.currentView === 'string'
? window.currentView
: '';
const want = STATS_INTEREST_VIEWS.has(view);
if (
!force &&
lastStatsInterestSent === want &&
lastStatsInterestPeerId === conn.id
) {
return;
}
lastStatsInterestSent = want;
lastStatsInterestPeerId = conn.id;
try {
conn
.request(Methods.setStatsInterest || 'setStatsInterest', {
active: want,
view: view || undefined,
})
.catch(() => {
// older servers may lack the method — ignore
lastStatsInterestSent = null;
});
} catch {
lastStatsInterestSent = null;
}
}
if (typeof window !== 'undefined') {
window.syncStatsInterest = syncStatsInterest;
}
// Utility functions are now imported from uiUtils.js // Utility functions are now imported from uiUtils.js
@@ -1384,6 +1468,13 @@ function navigateToView(viewName, opts = {}) {
// Expose for auto-refresh poller // Expose for auto-refresh poller
if (typeof window !== 'undefined') window.currentView = viewName; if (typeof window !== 'undefined') window.currentView = viewName;
// Demand-driven fleet stats: only collect while on Containers / Dashboard / details
try {
syncStatsInterest();
} catch {
// ignore
}
// Drop live streams / PTYs when leaving container details // Drop live streams / PTYs when leaving container details
if (leavingDetails) { if (leavingDetails) {
if (typeof stopDetailsLogs === 'function') stopDetailsLogs(); if (typeof stopDetailsLogs === 'function') stopDetailsLogs();
+7
View File
@@ -385,6 +385,13 @@ export const api = {
return connOrActive(connection).request(Methods.updateStatsConfig, args) return connOrActive(connection).request(Methods.updateStatsConfig, args)
}, },
setStatsInterest(active, view, connection) {
return connOrActive(connection).request(Methods.setStatsInterest, {
active: Boolean(active),
view: view || undefined,
})
},
getHostSnapshot(connection) { getHostSnapshot(connection) {
return connOrActive(connection).request(Methods.getHostSnapshot, {}) return connOrActive(connection).request(Methods.getHostSnapshot, {})
}, },
+10 -10
View File
@@ -4,17 +4,17 @@
export const CONFIG = { export const CONFIG = {
// Stats collection (server/services/stats.js) // Stats collection (server/services/stats.js)
// Round-robin one-shot samples — not full-fleet stream:true (Dozzle-style streams // Demand-driven: live stream:true only while a client is on Containers /
// keep dockerd busy ~1Hz × N containers). See Settings → Performance. // Dashboard / container-details. Fully off otherwise. See Settings → Performance.
STATS: { STATS: {
INTERVAL_MS: 5000, // Min gap between allStats broadcasts INTERVAL_MS: 1000, // allStats push cadence while watching
ACTIVE_INTERVAL_MS: 5000, // Idle gap after each sample batch completes ACTIVE_INTERVAL_MS: 1000, // alias for broadcast when watching
IDLE_INTERVAL_MS: 5000, // Reserved (peer-idle uses full pause) IDLE_INTERVAL_MS: 5000, // reserved
CACHE_TTL_MS: 2000, // Per-container broadcast cache TTL CACHE_TTL_MS: 1000,
CONCURRENCY: 2, // Max parallel one-shot stats to dockerd CONCURRENCY: 2, // legacy (stream mode)
SAMPLES_PER_TICK: 4, // Max containers sampled per tick (round-robin) SAMPLES_PER_TICK: 4, // legacy (stream mode)
LIST_INTERVAL_MS: 15000, // How often to re-list containers LIST_INTERVAL_MS: 10000, // re-list running containers while watching
SMOOTHING_FACTOR: 0.2, // Client-side smoothing (if used) SMOOTHING_FACTOR: 0.2,
}, },
// UI updates // UI updates
+4 -12
View File
@@ -105,22 +105,14 @@ Subscribes to Engine events; fans out `push:dockerEvent` and may trigger list re
Periodic container stats → `push:allStats`; history buffer for charts. Periodic container stats → `push:allStats`; history buffer for charts.
**Important:** peardock does **not** open perpetual per-container `stream: true` stats (Dozzle does, and [documents elevated dockerd CPU](https://dozzle.dev/guide/faq) as expected). Instead: **Demand-driven real-time stats:** clients call `setStatsInterest({ active, view })` while on **Containers**, **Dashboard**, or **container details**. Only then does the server open per-container Docker `stats({ stream: true })` attachments and push `push:allStats` to **watchers** (not every connected peer). Leaving those views (or disconnecting) tears streams down immediately — dockerd is not sampled in the background.
1. **Round-robin one-shot** samples (`stream: false`) — at most `samplesPerTick` containers per batch
2. **Post-batch idle gap** — next batch waits `collectIntervalMs` after the previous finishes (no overlapping sweeps)
3. **Roster throttle**`listContainers` only every `listIntervalMs`
4. **Peer-idle pause** — no sampling when `peers.size === 0`
| Env / UI | Default | Meaning | | Env / UI | Default | Meaning |
|----------|---------|---------| |----------|---------|---------|
| Collect gap / `PEARDOCK_STATS_INTERVAL_MS` | 5000 | Idle after each sample batch | | UI update interval / `PEARDOCK_STATS_BROADCAST_MS` | 1000 | `push:allStats` cadence while watching |
| Samples/tick / `PEARDOCK_STATS_SAMPLES_PER_TICK` | 4 | Max containers sampled per batch (main load knob) | | Roster refresh / `PEARDOCK_STATS_LIST_INTERVAL_MS` | 10000 | Re-list running containers + attach streams |
| Concurrency / `PEARDOCK_STATS_CONCURRENCY` | 2 | Parallel one-shots within a batch |
| List interval / `PEARDOCK_STATS_LIST_INTERVAL_MS` | 15000 | Roster refresh |
| Broadcast / `PEARDOCK_STATS_BROADCAST_MS` | 5000 | Min gap between `push:allStats` |
Runtime values persist in `peardock-stats.json` (cwd or `PEARDOCK_STATS_PATH`). RPC: `getStatsConfig` (viewer), `updateStatsConfig` (admin). Settings → **Performance** applies live. On hot hosts: raise collect gap, keep samples/tick at 24 and concurrency at 12. RPC: `setStatsInterest` (viewer), `getStatsConfig` (viewer), `updateStatsConfig` (admin). Settings → **Performance**.
### Metrics (`services/metrics.js`) ### Metrics (`services/metrics.js`)
+17 -33
View File
@@ -2888,9 +2888,10 @@ services:
<div> <div>
<h3 class="mb-1">Docker stats collection <span class="badge text-bg-secondary">Server</span></h3> <h3 class="mb-1">Docker stats collection <span class="badge text-bg-secondary">Server</span></h3>
<p class="small text-muted mb-0"> <p class="small text-muted mb-0">
Controls how the <strong>connected peer</strong> samples container CPU/memory for the fleet UI. Live CPU/memory for the Containers table uses <strong>real-time Docker stats streams</strong>, but only while a client is on
Uses short one-shot polls (not perpetual streams) so dockerd load stays bounded. <strong>Containers</strong>, <strong>Dashboard</strong>, or <strong>container details</strong>.
Requires an <strong>admin</strong> session to change. Values persist on the server as <code>peardock-stats.json</code>. Leaving those views stops collection immediately so dockerd is not sampled in the background.
Requires <strong>admin</strong> to change timings. Persists as <code>peardock-stats.json</code>.
</p> </p>
</div> </div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
@@ -2903,44 +2904,22 @@ services:
<div id="stats-offline-hint" class="alert alert-warning py-2 small hidden"> <div id="stats-offline-hint" class="alert alert-warning py-2 small hidden">
Connect to a PearDock server to view or change stats collection settings. Connect to a PearDock server to view or change stats collection settings.
</div> </div>
<p class="small text-muted mb-2">Changes apply live on the server — no restart and no Save button.</p> <p class="small text-muted mb-2">Changes apply live — no restart. Collection is still off when no client is watching stats views.</p>
<div class="row g-3 mt-1"> <div class="row g-3 mt-1">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label" for="stats-collect-sec">Idle gap after batch (sec)</label> <label class="form-label" for="stats-broadcast-sec">UI update interval (sec)</label>
<input type="number" id="stats-collect-sec" class="form-control bg-dark text-white stats-live-field" data-min-role="admin" min="1" max="60" step="1" value="5"> <input type="number" id="stats-broadcast-sec" class="form-control bg-dark text-white stats-live-field" data-min-role="admin" min="1" max="30" step="1" value="1">
<div class="form-text">Wait this long <em>after</em> a sample batch finishes before the next. Higher = less dockerd CPU.</div> <div class="form-text">How often <code>push:allStats</code> refreshes the table while watching (1 = near real-time).</div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label" for="stats-samples-per-tick">Samples per tick</label> <label class="form-label" for="stats-list-sec">Roster refresh (sec)</label>
<input type="number" id="stats-samples-per-tick" class="form-control bg-dark text-white stats-live-field" data-min-role="admin" min="1" max="100" step="1" value="4"> <input type="number" id="stats-list-sec" class="form-control bg-dark text-white stats-live-field" data-min-role="admin" min="2" max="120" step="1" value="10">
<div class="form-text">Containers sampled each tick (round-robin). <strong>Main load control</strong> — do not set to fleet size.</div> <div class="form-text">How often to re-list running containers and attach/detach streams.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="stats-concurrency">Sample concurrency</label>
<input type="number" id="stats-concurrency" class="form-control bg-dark text-white stats-live-field" data-min-role="admin" min="1" max="32" step="1" value="2">
<div class="form-text">Max parallel one-shot stats to dockerd within a tick. Keep at 12 on busy hosts.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="stats-broadcast-sec">Broadcast interval (sec)</label>
<input type="number" id="stats-broadcast-sec" class="form-control bg-dark text-white stats-live-field" data-min-role="admin" min="1" max="60" step="1" value="5">
<div class="form-text">Min gap between <code>push:allStats</code> to clients (can reuse last samples).</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label" for="stats-list-sec">List containers every (sec)</label>
<input type="number" id="stats-list-sec" class="form-control bg-dark text-white stats-live-field" data-min-role="admin" min="2" max="120" step="1" value="15">
<div class="form-text">Roster refresh rate (start/stop detection for the sampler).</div>
</div>
<div class="col-md-3">
<label class="form-label" for="stats-cache-ttl-ms">Idle cache TTL (ms)</label>
<input type="number" id="stats-cache-ttl-ms" class="form-control bg-dark text-white stats-live-field" data-min-role="admin" min="0" max="30000" step="100" value="2000">
<div class="form-text">Reuse last sample for idle containers when broadcasting.</div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label" for="stats-warn-containers">Large-fleet warn threshold</label> <label class="form-label" for="stats-warn-containers">Large-fleet warn threshold</label>
<input type="number" id="stats-warn-containers" class="form-control bg-dark text-white stats-live-field" data-min-role="admin" min="10" max="10000" step="1" value="80"> <input type="number" id="stats-warn-containers" class="form-control bg-dark text-white stats-live-field" data-min-role="admin" min="10" max="10000" step="1" value="80">
<div class="form-text">One-time server log when this many containers are running.</div> <div class="form-text">One-time server log when this many streams would open.</div>
</div> </div>
<div class="col-md-3 d-flex align-items-end flex-wrap gap-2"> <div class="col-md-3 d-flex align-items-end flex-wrap gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="stats-reset-defaults" data-min-role="admin" title="Reset to server defaults"> <button type="button" class="btn btn-outline-secondary btn-sm" id="stats-reset-defaults" data-min-role="admin" title="Reset to server defaults">
@@ -2948,6 +2927,11 @@ services:
</button> </button>
</div> </div>
</div> </div>
<!-- Hidden legacy fields so older scripts do not throw; not shown in UI -->
<input type="hidden" id="stats-collect-sec" class="stats-live-field" value="1">
<input type="hidden" id="stats-samples-per-tick" class="stats-live-field" value="4">
<input type="hidden" id="stats-concurrency" class="stats-live-field" value="2">
<input type="hidden" id="stats-cache-ttl-ms" class="stats-live-field" value="1000">
<div id="stats-status" class="small text-muted mt-2" role="status" aria-live="polite"></div> <div id="stats-status" class="small text-muted mt-2" role="status" aria-live="polite"></div>
<div id="stats-runtime-meta" class="small text-muted mt-2 font-monospace"></div> <div id="stats-runtime-meta" class="small text-muted mt-2 font-monospace"></div>
</div> </div>
+16
View File
@@ -18,6 +18,7 @@ import {
import { import {
getStatsConfig, getStatsConfig,
updateStatsConfig, updateStatsConfig,
setPeerStatsInterest,
getStatsFilePath, getStatsFilePath,
STATS_LIMITS, STATS_LIMITS,
} from '../services/stats.js' } from '../services/stats.js'
@@ -210,6 +211,21 @@ export function registerSystemHandlers(session) {
return { success: true, config } return { success: true, config }
}) })
/**
* Client declares whether it needs live fleet stats for the current UI view.
* Server only opens Docker stats streams while ≥1 peer has active interest.
*/
session.respond(
'setStatsInterest',
async (args = {}) => {
const active = args.active === true || args.active === 1 || args.active === '1'
const view = args.view != null ? String(args.view) : undefined
const config = setPeerStatsInterest(session.id, active, { view })
return { success: true, active, config }
},
{ hot: true }
)
session.respond('listSchedules', async () => { session.respond('listSchedules', async () => {
return { success: true, schedules: listSchedules() } return { success: true, schedules: listSchedules() }
}) })
+6
View File
@@ -21,6 +21,7 @@ import { registerBinaryStreamHandlers } from './binary-stream.js'
import { registerSuggestionHandlers } from '../handlers/suggestions.js' import { registerSuggestionHandlers } from '../handlers/suggestions.js'
import { registerTunnelHandlers } from '../handlers/tunnels.js' import { registerTunnelHandlers } from '../handlers/tunnels.js'
import { registerAlertHandlers } from '../handlers/alerts.js' import { registerAlertHandlers } from '../handlers/alerts.js'
import { clearPeerStatsInterest } from '../services/stats.js'
/** /**
* @param {import('./session.js').PeerSession} session * @param {import('./session.js').PeerSession} session
@@ -54,6 +55,11 @@ export function registerAllHandlers(session) {
export function cleanupSession(session) { export function cleanupSession(session) {
cleanupTerminalOnClose(session) cleanupTerminalOnClose(session)
cleanupLogsOnClose(session) cleanupLogsOnClose(session)
try {
clearPeerStatsInterest(session.id)
} catch {
// ignore
}
for (const [key, value] of session.state.entries()) { for (const [key, value] of session.state.entries()) {
if (key.startsWith('exec:') && value?.stream) { if (key.startsWith('exec:') && value?.stream) {
try { try {
+424 -495
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -43,6 +43,8 @@ export const MethodRoles = Object.freeze({
getMetrics: Roles.viewer, getMetrics: Roles.viewer,
listSchedules: Roles.viewer, listSchedules: Roles.viewer,
getStatsConfig: Roles.viewer, getStatsConfig: Roles.viewer,
/** Client view interest for live fleet stats (Containers / Dashboard) */
setStatsInterest: Roles.viewer,
browseVolume: Roles.viewer, browseVolume: Roles.viewer,
getAlertsConfig: Roles.viewer, getAlertsConfig: Roles.viewer,
getAlertsStatus: Roles.viewer, getAlertsStatus: Roles.viewer,
@@ -317,6 +319,7 @@ export const Methods = Object.freeze({
// Docker stats collection (server-side, Settings → Performance) // Docker stats collection (server-side, Settings → Performance)
getStatsConfig: 'getStatsConfig', getStatsConfig: 'getStatsConfig',
updateStatsConfig: 'updateStatsConfig', updateStatsConfig: 'updateStatsConfig',
setStatsInterest: 'setStatsInterest',
// Alerts / webhooks (server-side) // Alerts / webhooks (server-side)
getAlertsConfig: 'getAlertsConfig', getAlertsConfig: 'getAlertsConfig',
getAlertsStatus: 'getAlertsStatus', getAlertsStatus: 'getAlertsStatus',
+9 -3
View File
@@ -33,17 +33,23 @@ test('normalizeStatsConfig clamps out-of-range values', (t) => {
test('normalizeStatsConfig accepts seconds aliases from UI', (t) => { test('normalizeStatsConfig accepts seconds aliases from UI', (t) => {
const n = normalizeStatsConfig({ const n = normalizeStatsConfig({
collectIntervalSec: 5, collectIntervalSec: 5, // legacy alias → broadcast when broadcast unset
broadcastIntervalSec: 3,
listIntervalSec: 20, listIntervalSec: 20,
concurrency: 4, concurrency: 4,
samplesPerTick: 8, samplesPerTick: 8,
}) })
t.is(n.broadcastIntervalMs, 5000)
t.is(n.collectIntervalMs, 5000) t.is(n.collectIntervalMs, 5000)
t.is(n.broadcastIntervalMs, 3000)
t.is(n.listIntervalMs, 20000) t.is(n.listIntervalMs, 20000)
t.is(n.concurrency, 4) t.is(n.concurrency, 4)
t.is(n.samplesPerTick, 8) t.is(n.samplesPerTick, 8)
const n2 = normalizeStatsConfig({
broadcastIntervalSec: 3,
collectIntervalSec: 9,
})
// explicit broadcast wins over legacy collect alias
t.is(n2.broadcastIntervalMs, 3000)
}) })
test('defaultStatsConfig is within limits', (t) => { test('defaultStatsConfig is within limits', (t) => {
+43 -11
View File
@@ -1,5 +1,5 @@
/** /**
* Peer-demand stats: no Docker collection while zero peers. * Peer-demand stats: no Docker collection until a peer sets view interest.
*/ */
import test from 'brittle' import test from 'brittle'
import { PeerRegistry } from '../server/core/peer-registry.js' import { PeerRegistry } from '../server/core/peer-registry.js'
@@ -29,7 +29,6 @@ test('PeerRegistry onChange fires on add/remove and size transitions', (t) => {
t.is(reg.size, 0) t.is(reg.size, 0)
t.alike(events[events.length - 1], [0, 1]) t.alike(events[events.length - 1], [0, 1])
// remove missing id is no-op (no event)
const n = events.length const n = events.length
reg.remove('nope') reg.remove('nope')
t.is(events.length, n) t.is(events.length, n)
@@ -58,17 +57,16 @@ test('PeerRegistry.broadcast is a no-op with zero peers', (t) => {
reg.clear() reg.clear()
}) })
test('stats service: idle when no peers; active when peer present', async (t) => { test('stats service: idle until view interest; pauses when interest cleared', async (t) => {
// Isolate module state by dynamic import after resetting — use public API only.
const stats = await import('../server/services/stats.js') const stats = await import('../server/services/stats.js')
const { peers } = await import('../server/core/peer-registry.js') const { peers } = await import('../server/core/peer-registry.js')
// Ensure clean slate from any prior test importing peers
peers.clear() peers.clear()
stats.stopStatsBroadcast() stats.stopStatsBroadcast()
stats.resetStatsConfigForTests?.()
stats.startStatsBroadcast() stats.startStatsBroadcast()
t.is(stats.isStatsCollectionActive(), false, 'no collect interval with 0 peers') t.is(stats.isStatsCollectionActive(), false, 'idle with no interest')
const fake = { const fake = {
id: 'test-peer-stats-idle', id: 'test-peer-stats-idle',
@@ -77,13 +75,47 @@ test('stats service: idle when no peers; active when peer present', async (t) =>
destroy() {}, destroy() {},
} }
peers.add(fake) peers.add(fake)
// resume is async-kicked; give microtask + timer a tick await new Promise((r) => setTimeout(r, 30))
await new Promise((r) => setTimeout(r, 50)) // Connected alone is NOT enough — must declare interest
t.is(stats.isStatsCollectionActive(), true, 'collect interval after first peer') t.is(stats.isStatsCollectionActive(), false, 'peer connected but no view interest')
peers.remove(fake.id) stats.setPeerStatsInterest(fake.id, true, { view: 'containers' })
await new Promise((r) => setTimeout(r, 30))
t.is(stats.isStatsCollectionActive(), true, 'active after setStatsInterest')
t.is(stats.getStatsInterestCount(), 1)
stats.setPeerStatsInterest(fake.id, false)
await new Promise((r) => setTimeout(r, 20)) await new Promise((r) => setTimeout(r, 20))
t.is(stats.isStatsCollectionActive(), false, 'collect interval stopped after last peer') t.is(stats.isStatsCollectionActive(), false, 'paused after interest cleared')
t.is(stats.getStatsInterestCount(), 0)
stats.stopStatsBroadcast()
peers.clear()
})
test('stats interest cleared when peer removed from registry', async (t) => {
const stats = await import('../server/services/stats.js')
const { peers } = await import('../server/core/peer-registry.js')
peers.clear()
stats.stopStatsBroadcast()
stats.resetStatsConfigForTests?.()
stats.startStatsBroadcast()
const fake = {
id: 'test-peer-stats-drop',
role: 'viewer',
push() {},
destroy() {},
}
peers.add(fake)
stats.setPeerStatsInterest(fake.id, true, { view: 'dashboard' })
t.is(stats.isStatsCollectionActive(), true)
peers.remove(fake.id)
await new Promise((r) => setTimeout(r, 20))
t.is(stats.isStatsCollectionActive(), false, 'paused after peer disconnect')
t.is(stats.getStatsInterestCount(), 0)
stats.stopStatsBroadcast() stats.stopStatsBroadcast()
peers.clear() peers.clear()
+20 -33
View File
@@ -1,6 +1,6 @@
/** /**
* Settings → Performance * Settings → Performance
* Server-side Docker stats collection (interval, concurrency). * Server-side Docker stats (view-interest streams + push cadence).
*/ */
import { Methods } from '../shared/protocol.js' import { Methods } from '../shared/protocol.js'
import { manager } from '../client/manager.js' import { manager } from '../client/manager.js'
@@ -8,7 +8,6 @@ import { presentError } from '../client/errors.js'
/** @type {object|null} */ /** @type {object|null} */
let statsState = null let statsState = null
/** Skip auto-apply while hydrating form from server */
let suppressLiveApply = false let suppressLiveApply = false
/** @type {ReturnType<typeof setTimeout>|null} */ /** @type {ReturnType<typeof setTimeout>|null} */
let applyTimer = null let applyTimer = null
@@ -72,47 +71,42 @@ function renderStatsForm(config) {
if (!config) return if (!config) return
suppressLiveApply = true suppressLiveApply = true
try { try {
const collect = document.getElementById('stats-collect-sec')
if (collect) {
collect.value = String(
config.collectIntervalSec ?? Math.round((config.collectIntervalMs || 5000) / 1000)
)
}
const broadcast = document.getElementById('stats-broadcast-sec') const broadcast = document.getElementById('stats-broadcast-sec')
if (broadcast) { if (broadcast) {
broadcast.value = String( broadcast.value = String(
config.broadcastIntervalSec ?? Math.round((config.broadcastIntervalMs || 5000) / 1000) config.broadcastIntervalSec ?? Math.round((config.broadcastIntervalMs || 1000) / 1000)
) )
} }
const samples = document.getElementById('stats-samples-per-tick')
if (samples) samples.value = String(config.samplesPerTick ?? 4)
const conc = document.getElementById('stats-concurrency')
if (conc) conc.value = String(config.concurrency ?? 2)
const listSec = document.getElementById('stats-list-sec') const listSec = document.getElementById('stats-list-sec')
if (listSec) { if (listSec) {
listSec.value = String( listSec.value = String(
config.listIntervalSec ?? Math.round((config.listIntervalMs || 15000) / 1000) config.listIntervalSec ?? Math.round((config.listIntervalMs || 10000) / 1000)
) )
} }
const cache = document.getElementById('stats-cache-ttl-ms')
if (cache) cache.value = String(config.cacheTtlMs ?? 2000)
const warn = document.getElementById('stats-warn-containers') const warn = document.getElementById('stats-warn-containers')
if (warn) warn.value = String(config.warnContainers ?? 80) if (warn) warn.value = String(config.warnContainers ?? 80)
const active = config.active ? 'collecting' : 'idle (no peers or paused)' // Keep hidden legacy inputs in sync if present
setEngineStatus( const collect = document.getElementById('stats-collect-sec')
`${active} · gap ${config.collectIntervalSec ?? '?'}s · ${config.samplesPerTick ?? '?'} samples · ×${config.concurrency ?? '?'}` if (collect) {
collect.value = String(
config.broadcastIntervalSec ?? Math.round((config.broadcastIntervalMs || 1000) / 1000)
) )
}
const active = config.active
? `streaming · ${config.streams ?? '?'} streams · ${config.watchers ?? 0} watcher(s)`
: 'idle (no client on Containers/Dashboard)'
setEngineStatus(active)
const meta = document.getElementById('stats-runtime-meta') const meta = document.getElementById('stats-runtime-meta')
if (meta) { if (meta) {
const parts = [ const parts = [
`mode=${config.mode || 'interest-stream'}`,
`peers=${config.peers ?? 0}`, `peers=${config.peers ?? 0}`,
`running=${config.runningTracked ?? '?'}`, `watchers=${config.watchers ?? 0}`,
`gap=${config.collectIntervalMs}ms`, `streams=${config.streams ?? 0}`,
`samples/tick=${config.samplesPerTick}`,
`concurrency=${config.concurrency}`,
`list=${config.listIntervalMs}ms`,
`broadcast=${config.broadcastIntervalMs}ms`, `broadcast=${config.broadcastIntervalMs}ms`,
`list=${config.listIntervalMs}ms`,
] ]
if (config.path) parts.push(`file=${config.path}`) if (config.path) parts.push(`file=${config.path}`)
meta.textContent = parts.join(' · ') meta.textContent = parts.join(' · ')
@@ -125,20 +119,12 @@ function renderStatsForm(config) {
} }
function readFormPartial() { function readFormPartial() {
const collectSec = Number(document.getElementById('stats-collect-sec')?.value)
const broadcastSec = Number(document.getElementById('stats-broadcast-sec')?.value) const broadcastSec = Number(document.getElementById('stats-broadcast-sec')?.value)
const samplesPerTick = Number(document.getElementById('stats-samples-per-tick')?.value)
const concurrency = Number(document.getElementById('stats-concurrency')?.value)
const listSec = Number(document.getElementById('stats-list-sec')?.value) const listSec = Number(document.getElementById('stats-list-sec')?.value)
const cacheTtlMs = Number(document.getElementById('stats-cache-ttl-ms')?.value)
const warnContainers = Number(document.getElementById('stats-warn-containers')?.value) const warnContainers = Number(document.getElementById('stats-warn-containers')?.value)
return { return {
collectIntervalSec: Number.isFinite(collectSec) ? collectSec : undefined,
broadcastIntervalSec: Number.isFinite(broadcastSec) ? broadcastSec : undefined, broadcastIntervalSec: Number.isFinite(broadcastSec) ? broadcastSec : undefined,
samplesPerTick: Number.isFinite(samplesPerTick) ? samplesPerTick : undefined,
concurrency: Number.isFinite(concurrency) ? concurrency : undefined,
listIntervalSec: Number.isFinite(listSec) ? listSec : undefined, listIntervalSec: Number.isFinite(listSec) ? listSec : undefined,
cacheTtlMs: Number.isFinite(cacheTtlMs) ? cacheTtlMs : undefined,
warnContainers: Number.isFinite(warnContainers) ? warnContainers : undefined, warnContainers: Number.isFinite(warnContainers) ? warnContainers : undefined,
} }
} }
@@ -209,9 +195,10 @@ export function initStatsSettings() {
panel.dataset.statsWired = '1' panel.dataset.statsWired = '1'
panel.querySelectorAll('.stats-live-field').forEach((el) => { panel.querySelectorAll('.stats-live-field').forEach((el) => {
// Skip hidden legacy fields from auto-apply noise
if (el.type === 'hidden') return
el.addEventListener('change', () => scheduleApply()) el.addEventListener('change', () => scheduleApply())
el.addEventListener('input', () => { el.addEventListener('input', () => {
// Debounce number typing
if (el.tagName === 'INPUT') scheduleApply() if (el.tagName === 'INPUT') scheduleApply()
}) })
}) })