This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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, {})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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 2–4 and concurrency at 1–2.
|
RPC: `setStatsInterest` (viewer), `getStatsConfig` (viewer), `updateStatsConfig` (admin). Settings → **Performance**.
|
||||||
|
|
||||||
### Metrics (`services/metrics.js`)
|
### Metrics (`services/metrics.js`)
|
||||||
|
|
||||||
|
|||||||
+17
-33
@@ -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 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>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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() }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+462
-533
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
+21
-34
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user