This commit is contained in:
@@ -125,6 +125,8 @@ manager.on('disconnect', (conn) => {
|
||||
}
|
||||
if (!manager.active?.connected) {
|
||||
updateHealthBadge(null);
|
||||
lastStatsInterestSent = null;
|
||||
lastStatsInterestPeerId = null;
|
||||
}
|
||||
refreshFleetIfVisible();
|
||||
if (isBootRestoring) return;
|
||||
@@ -133,6 +135,8 @@ manager.on('disconnect', (conn) => {
|
||||
if (!hasActiveConnection()) {
|
||||
resetContainerList();
|
||||
stopStatsInterval();
|
||||
lastStatsInterestSent = null;
|
||||
lastStatsInterestPeerId = null;
|
||||
if (typeof manager.isReconnecting === 'function' && manager.isReconnecting()) {
|
||||
// reconnecting banner is driven by 'reconnecting' events
|
||||
return;
|
||||
@@ -160,6 +164,26 @@ manager.on('connect', (conn) => {
|
||||
{ latency: conn.latency, docker: conn.dockerHealth, status: conn.healthStatus },
|
||||
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) */
|
||||
@@ -419,6 +443,66 @@ function startStatsInterval() {
|
||||
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
|
||||
|
||||
|
||||
@@ -1384,6 +1468,13 @@ function navigateToView(viewName, opts = {}) {
|
||||
// Expose for auto-refresh poller
|
||||
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
|
||||
if (leavingDetails) {
|
||||
if (typeof stopDetailsLogs === 'function') stopDetailsLogs();
|
||||
|
||||
@@ -385,6 +385,13 @@ export const api = {
|
||||
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) {
|
||||
return connOrActive(connection).request(Methods.getHostSnapshot, {})
|
||||
},
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
|
||||
export const CONFIG = {
|
||||
// Stats collection (server/services/stats.js)
|
||||
// Round-robin one-shot samples — not full-fleet stream:true (Dozzle-style streams
|
||||
// keep dockerd busy ~1Hz × N containers). See Settings → Performance.
|
||||
// Demand-driven: live stream:true only while a client is on Containers /
|
||||
// Dashboard / container-details. Fully off otherwise. See Settings → Performance.
|
||||
STATS: {
|
||||
INTERVAL_MS: 5000, // Min gap between allStats broadcasts
|
||||
ACTIVE_INTERVAL_MS: 5000, // Idle gap after each sample batch completes
|
||||
IDLE_INTERVAL_MS: 5000, // Reserved (peer-idle uses full pause)
|
||||
CACHE_TTL_MS: 2000, // Per-container broadcast cache TTL
|
||||
CONCURRENCY: 2, // Max parallel one-shot stats to dockerd
|
||||
SAMPLES_PER_TICK: 4, // Max containers sampled per tick (round-robin)
|
||||
LIST_INTERVAL_MS: 15000, // How often to re-list containers
|
||||
SMOOTHING_FACTOR: 0.2, // Client-side smoothing (if used)
|
||||
INTERVAL_MS: 1000, // allStats push cadence while watching
|
||||
ACTIVE_INTERVAL_MS: 1000, // alias for broadcast when watching
|
||||
IDLE_INTERVAL_MS: 5000, // reserved
|
||||
CACHE_TTL_MS: 1000,
|
||||
CONCURRENCY: 2, // legacy (stream mode)
|
||||
SAMPLES_PER_TICK: 4, // legacy (stream mode)
|
||||
LIST_INTERVAL_MS: 10000, // re-list running containers while watching
|
||||
SMOOTHING_FACTOR: 0.2,
|
||||
},
|
||||
|
||||
// UI updates
|
||||
|
||||
+4
-12
@@ -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.
|
||||
|
||||
**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:
|
||||
|
||||
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`
|
||||
**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.
|
||||
|
||||
| Env / UI | Default | Meaning |
|
||||
|----------|---------|---------|
|
||||
| Collect gap / `PEARDOCK_STATS_INTERVAL_MS` | 5000 | Idle after each sample batch |
|
||||
| Samples/tick / `PEARDOCK_STATS_SAMPLES_PER_TICK` | 4 | Max containers sampled per batch (main load knob) |
|
||||
| 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` |
|
||||
| UI update interval / `PEARDOCK_STATS_BROADCAST_MS` | 1000 | `push:allStats` cadence while watching |
|
||||
| Roster refresh / `PEARDOCK_STATS_LIST_INTERVAL_MS` | 10000 | Re-list running containers + attach streams |
|
||||
|
||||
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 2–4 and concurrency at 1–2.
|
||||
RPC: `setStatsInterest` (viewer), `getStatsConfig` (viewer), `updateStatsConfig` (admin). Settings → **Performance**.
|
||||
|
||||
### Metrics (`services/metrics.js`)
|
||||
|
||||
|
||||
+17
-33
@@ -2888,9 +2888,10 @@ services:
|
||||
<div>
|
||||
<h3 class="mb-1">Docker stats collection <span class="badge text-bg-secondary">Server</span></h3>
|
||||
<p class="small text-muted mb-0">
|
||||
Controls how the <strong>connected peer</strong> samples container CPU/memory for the fleet UI.
|
||||
Uses short one-shot polls (not perpetual streams) so dockerd load stays bounded.
|
||||
Requires an <strong>admin</strong> session to change. Values persist on the server as <code>peardock-stats.json</code>.
|
||||
Live CPU/memory for the Containers table uses <strong>real-time Docker stats streams</strong>, but only while a client is on
|
||||
<strong>Containers</strong>, <strong>Dashboard</strong>, or <strong>container details</strong>.
|
||||
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>
|
||||
</div>
|
||||
<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">
|
||||
Connect to a PearDock server to view or change stats collection settings.
|
||||
</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="col-md-3">
|
||||
<label class="form-label" for="stats-collect-sec">Idle gap after batch (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">
|
||||
<div class="form-text">Wait this long <em>after</em> a sample batch finishes before the next. Higher = less dockerd CPU.</div>
|
||||
<label class="form-label" for="stats-broadcast-sec">UI update 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="30" step="1" value="1">
|
||||
<div class="form-text">How often <code>push:allStats</code> refreshes the table while watching (1 = near real-time).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="stats-samples-per-tick">Samples per tick</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">
|
||||
<div class="form-text">Containers sampled each tick (round-robin). <strong>Main load control</strong> — do not set to fleet size.</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 1–2 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>
|
||||
<label class="form-label" for="stats-list-sec">Roster refresh (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="10">
|
||||
<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-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">
|
||||
<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 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">
|
||||
@@ -2948,6 +2927,11 @@ services:
|
||||
</button>
|
||||
</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-runtime-meta" class="small text-muted mt-2 font-monospace"></div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
getStatsConfig,
|
||||
updateStatsConfig,
|
||||
setPeerStatsInterest,
|
||||
getStatsFilePath,
|
||||
STATS_LIMITS,
|
||||
} from '../services/stats.js'
|
||||
@@ -210,6 +211,21 @@ export function registerSystemHandlers(session) {
|
||||
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 () => {
|
||||
return { success: true, schedules: listSchedules() }
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import { registerBinaryStreamHandlers } from './binary-stream.js'
|
||||
import { registerSuggestionHandlers } from '../handlers/suggestions.js'
|
||||
import { registerTunnelHandlers } from '../handlers/tunnels.js'
|
||||
import { registerAlertHandlers } from '../handlers/alerts.js'
|
||||
import { clearPeerStatsInterest } from '../services/stats.js'
|
||||
|
||||
/**
|
||||
* @param {import('./session.js').PeerSession} session
|
||||
@@ -54,6 +55,11 @@ export function registerAllHandlers(session) {
|
||||
export function cleanupSession(session) {
|
||||
cleanupTerminalOnClose(session)
|
||||
cleanupLogsOnClose(session)
|
||||
try {
|
||||
clearPeerStatsInterest(session.id)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
for (const [key, value] of session.state.entries()) {
|
||||
if (key.startsWith('exec:') && value?.stream) {
|
||||
try {
|
||||
|
||||
+424
-495
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,8 @@ export const MethodRoles = Object.freeze({
|
||||
getMetrics: Roles.viewer,
|
||||
listSchedules: Roles.viewer,
|
||||
getStatsConfig: Roles.viewer,
|
||||
/** Client view interest for live fleet stats (Containers / Dashboard) */
|
||||
setStatsInterest: Roles.viewer,
|
||||
browseVolume: Roles.viewer,
|
||||
getAlertsConfig: Roles.viewer,
|
||||
getAlertsStatus: Roles.viewer,
|
||||
@@ -317,6 +319,7 @@ export const Methods = Object.freeze({
|
||||
// Docker stats collection (server-side, Settings → Performance)
|
||||
getStatsConfig: 'getStatsConfig',
|
||||
updateStatsConfig: 'updateStatsConfig',
|
||||
setStatsInterest: 'setStatsInterest',
|
||||
// Alerts / webhooks (server-side)
|
||||
getAlertsConfig: 'getAlertsConfig',
|
||||
getAlertsStatus: 'getAlertsStatus',
|
||||
|
||||
@@ -33,17 +33,23 @@ test('normalizeStatsConfig clamps out-of-range values', (t) => {
|
||||
|
||||
test('normalizeStatsConfig accepts seconds aliases from UI', (t) => {
|
||||
const n = normalizeStatsConfig({
|
||||
collectIntervalSec: 5,
|
||||
broadcastIntervalSec: 3,
|
||||
collectIntervalSec: 5, // legacy alias → broadcast when broadcast unset
|
||||
listIntervalSec: 20,
|
||||
concurrency: 4,
|
||||
samplesPerTick: 8,
|
||||
})
|
||||
t.is(n.broadcastIntervalMs, 5000)
|
||||
t.is(n.collectIntervalMs, 5000)
|
||||
t.is(n.broadcastIntervalMs, 3000)
|
||||
t.is(n.listIntervalMs, 20000)
|
||||
t.is(n.concurrency, 4)
|
||||
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) => {
|
||||
|
||||
+43
-11
@@ -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 { 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.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)
|
||||
@@ -58,17 +57,16 @@ test('PeerRegistry.broadcast is a no-op with zero peers', (t) => {
|
||||
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.
|
||||
test('stats service: idle until view interest; pauses when interest cleared', async (t) => {
|
||||
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.resetStatsConfigForTests?.()
|
||||
|
||||
stats.startStatsBroadcast()
|
||||
t.is(stats.isStatsCollectionActive(), false, 'no collect interval with 0 peers')
|
||||
t.is(stats.isStatsCollectionActive(), false, 'idle with no interest')
|
||||
|
||||
const fake = {
|
||||
id: 'test-peer-stats-idle',
|
||||
@@ -77,13 +75,47 @@ test('stats service: idle when no peers; active when peer present', async (t) =>
|
||||
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')
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
// Connected alone is NOT enough — must declare interest
|
||||
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))
|
||||
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()
|
||||
peers.clear()
|
||||
|
||||
+20
-33
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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 { manager } from '../client/manager.js'
|
||||
@@ -8,7 +8,6 @@ import { presentError } from '../client/errors.js'
|
||||
|
||||
/** @type {object|null} */
|
||||
let statsState = null
|
||||
/** Skip auto-apply while hydrating form from server */
|
||||
let suppressLiveApply = false
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
let applyTimer = null
|
||||
@@ -72,47 +71,42 @@ function renderStatsForm(config) {
|
||||
if (!config) return
|
||||
suppressLiveApply = true
|
||||
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')
|
||||
if (broadcast) {
|
||||
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')
|
||||
if (listSec) {
|
||||
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')
|
||||
if (warn) warn.value = String(config.warnContainers ?? 80)
|
||||
|
||||
const active = config.active ? 'collecting' : 'idle (no peers or paused)'
|
||||
setEngineStatus(
|
||||
`${active} · gap ${config.collectIntervalSec ?? '?'}s · ${config.samplesPerTick ?? '?'} samples · ×${config.concurrency ?? '?'}`
|
||||
// Keep hidden legacy inputs in sync if present
|
||||
const collect = document.getElementById('stats-collect-sec')
|
||||
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')
|
||||
if (meta) {
|
||||
const parts = [
|
||||
`mode=${config.mode || 'interest-stream'}`,
|
||||
`peers=${config.peers ?? 0}`,
|
||||
`running=${config.runningTracked ?? '?'}`,
|
||||
`gap=${config.collectIntervalMs}ms`,
|
||||
`samples/tick=${config.samplesPerTick}`,
|
||||
`concurrency=${config.concurrency}`,
|
||||
`list=${config.listIntervalMs}ms`,
|
||||
`watchers=${config.watchers ?? 0}`,
|
||||
`streams=${config.streams ?? 0}`,
|
||||
`broadcast=${config.broadcastIntervalMs}ms`,
|
||||
`list=${config.listIntervalMs}ms`,
|
||||
]
|
||||
if (config.path) parts.push(`file=${config.path}`)
|
||||
meta.textContent = parts.join(' · ')
|
||||
@@ -125,20 +119,12 @@ function renderStatsForm(config) {
|
||||
}
|
||||
|
||||
function readFormPartial() {
|
||||
const collectSec = Number(document.getElementById('stats-collect-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 cacheTtlMs = Number(document.getElementById('stats-cache-ttl-ms')?.value)
|
||||
const warnContainers = Number(document.getElementById('stats-warn-containers')?.value)
|
||||
return {
|
||||
collectIntervalSec: Number.isFinite(collectSec) ? collectSec : 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,
|
||||
cacheTtlMs: Number.isFinite(cacheTtlMs) ? cacheTtlMs : undefined,
|
||||
warnContainers: Number.isFinite(warnContainers) ? warnContainers : undefined,
|
||||
}
|
||||
}
|
||||
@@ -209,9 +195,10 @@ export function initStatsSettings() {
|
||||
panel.dataset.statsWired = '1'
|
||||
|
||||
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('input', () => {
|
||||
// Debounce number typing
|
||||
if (el.tagName === 'INPUT') scheduleApply()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user