dsh-flash-mem-mon 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/index.js +372 -372
- package/package.json +50 -45
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-flash-mem-mon
|
|
2
2
|
|
|
3
|
-
Memory monitor provider for [
|
|
3
|
+
Memory monitor provider for [dsh-flash](https://github.com/tcgbp/dsh-flash) — registers a host-memory-alert provider (RSS absolute thresholds, RSS growth rate, Major GC frequency) via `ctx.get('dockFlashAlerts')` and exposes `/memory-trend` for trend sparklines.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ Memory monitor provider for [dock-flash](https://github.com/tcgbp/dock-flash)
|
|
|
8
8
|
dsh plugin --profile <profile> add dsh-flash-mem-mon
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Requires `
|
|
11
|
+
Requires `dsh-flash >=1.0.0` (provides `dockFlashAlerts` and `quickControl` services).
|
|
12
12
|
|
|
13
13
|
## Configuration
|
|
14
14
|
|
package/README.zh-CN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-flash-mem-mon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
dsh-flash 的内存监控提供器 — 通过 `ctx.get('dockFlashAlerts')` 注册主机内存告警提供器(RSS 绝对阈值、RSS 增长率、Major GC 频率),并暴露 `/memory-trend` 路由用于趋势图。
|
|
4
4
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ dock-flash 的内存监控提供器 — 通过 `ctx.get('dockFlashAlerts')` 注
|
|
|
8
8
|
dsh plugin --profile <profile> add dsh-flash-mem-mon
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
需要 `
|
|
11
|
+
需要 `dsh-flash >=1.0.0`(提供 `dockFlashAlerts` 和 `quickControl` 服务)。
|
|
12
12
|
|
|
13
13
|
## 配置
|
|
14
14
|
|
package/dist/index.js
CHANGED
|
@@ -1,372 +1,372 @@
|
|
|
1
|
-
// Default export only (`export default Schema`); there is no named `Schema`.
|
|
2
|
-
import Schema from '@deepseek-ai/schemastery';
|
|
3
|
-
export const name = 'dsh-flash-mem-mon';
|
|
4
|
-
// No host-side service dependencies; all services are injected lazily.
|
|
5
|
-
export const inject = [];
|
|
6
|
-
// ── Memory alert thresholds (RSS MB) and GC thresholds (major GC/min) ──
|
|
7
|
-
const DEFAULT_MEM_THRESHOLD_INFO = 256;
|
|
8
|
-
const DEFAULT_MEM_THRESHOLD_WARNING = 512;
|
|
9
|
-
const DEFAULT_MEM_THRESHOLD_ERROR = 1024;
|
|
10
|
-
const DEFAULT_MEM_THRESHOLD_CRITICAL = 1536;
|
|
11
|
-
const DEFAULT_MEM_POLL_BASE = 30000;
|
|
12
|
-
const DEFAULT_MEM_POLL_MIN = 2000;
|
|
13
|
-
const DEFAULT_GC_THRESHOLD_INFO = 2;
|
|
14
|
-
const DEFAULT_GC_THRESHOLD_WARNING = 5;
|
|
15
|
-
const DEFAULT_GC_THRESHOLD_ERROR = 10;
|
|
16
|
-
// V8 GC entry `kind` is a bitmask of GC types:
|
|
17
|
-
// 1 << 0 = Scavenge (minor / young-gen)
|
|
18
|
-
// 1 << 1 = MarkSweepCompact (major / full)
|
|
19
|
-
// 1 << 2 = IncrementalMarking
|
|
20
|
-
// 1 << 3 = ProcessWeakCallbacks
|
|
21
|
-
// In practice modern V8/Node reports a full MarkSweepCompact cycle through the
|
|
22
|
-
// IncrementalMarking (4) and ProcessWeakCallbacks (8) phases — the standalone
|
|
23
|
-
// "major" bit (2) is almost never emitted on its own. Classifying Full GC by
|
|
24
|
-
// `kind === 2` alone therefore misses (nearly) every real full collection.
|
|
25
|
-
// The robust test: a Full GC is any NON-scavenge cycle, i.e. the Scavenge bit
|
|
26
|
-
// (1) is NOT set. This captures 2, 4, 6, 8, 10, 12, 14 while excluding minor
|
|
27
|
-
// (1) scavenges.
|
|
28
|
-
function isFullGc(kind) {
|
|
29
|
-
return (kind & 1) === 0 && kind > 0;
|
|
30
|
-
}
|
|
31
|
-
export const Config = Schema.object({
|
|
32
|
-
memThresholdInfo: Schema.number().default(DEFAULT_MEM_THRESHOLD_INFO).volatile(),
|
|
33
|
-
memThresholdWarning: Schema.number().default(DEFAULT_MEM_THRESHOLD_WARNING).volatile(),
|
|
34
|
-
memThresholdError: Schema.number().default(DEFAULT_MEM_THRESHOLD_ERROR).volatile(),
|
|
35
|
-
memThresholdCritical: Schema.number().default(DEFAULT_MEM_THRESHOLD_CRITICAL).volatile(),
|
|
36
|
-
memPollBase: Schema.number().default(DEFAULT_MEM_POLL_BASE).volatile(),
|
|
37
|
-
memPollMin: Schema.number().default(DEFAULT_MEM_POLL_MIN).volatile(),
|
|
38
|
-
gcThresholdInfo: Schema.number().default(DEFAULT_GC_THRESHOLD_INFO).volatile(),
|
|
39
|
-
gcThresholdWarning: Schema.number().default(DEFAULT_GC_THRESHOLD_WARNING).volatile(),
|
|
40
|
-
gcThresholdError: Schema.number().default(DEFAULT_GC_THRESHOLD_ERROR).volatile(),
|
|
41
|
-
});
|
|
42
|
-
class MemoryTrendCollector {
|
|
43
|
-
_samples = [];
|
|
44
|
-
_timer = null;
|
|
45
|
-
_cap;
|
|
46
|
-
// GC monitoring via PerformanceObserver
|
|
47
|
-
_gcObserver = null; // PerformanceObserver | null
|
|
48
|
-
_gcEvents = [];
|
|
49
|
-
_gcEventsCap = 2000; // ~5 min at worst-case GC frequency
|
|
50
|
-
/** Separate cap for major GC events only — they are rare and valuable for diagnostics. */
|
|
51
|
-
_gcMajorEvents = [];
|
|
52
|
-
_gcMajorEventsCap = 500; // Full GC events are rare; keep up to 500 (~hours)
|
|
53
|
-
constructor(cap = 720) {
|
|
54
|
-
this._cap = Math.max(10, cap);
|
|
55
|
-
}
|
|
56
|
-
start(baseInterval = 30_000, minInterval = 5_000) {
|
|
57
|
-
this.stop();
|
|
58
|
-
// Fire-and-forget: GC observer can start asynchronously; the poll loop
|
|
59
|
-
// below begins immediately regardless, since it does not depend on GC.
|
|
60
|
-
this._startGcObserver().catch(() => { });
|
|
61
|
-
const poll = () => {
|
|
62
|
-
const mu = process.memoryUsage();
|
|
63
|
-
this._push({
|
|
64
|
-
ts: Date.now(),
|
|
65
|
-
rss: mu.rss,
|
|
66
|
-
heapTotal: mu.heapTotal,
|
|
67
|
-
heapUsed: mu.heapUsed,
|
|
68
|
-
external: mu.external,
|
|
69
|
-
arrayBuffers: mu.arrayBuffers,
|
|
70
|
-
});
|
|
71
|
-
// Adaptive: higher heap usage → shorter interval (sample more densely).
|
|
72
|
-
const ratio = mu.heapUsed / mu.heapTotal;
|
|
73
|
-
const next = Math.max(minInterval, Math.round(baseInterval * Math.pow(1 - Math.min(ratio, 1), 2) + minInterval));
|
|
74
|
-
this._timer = setTimeout(poll, next);
|
|
75
|
-
// Unref so the timer never keeps the process alive on its own.
|
|
76
|
-
if (this._timer && typeof this._timer === 'object' && 'unref' in this._timer) {
|
|
77
|
-
this._timer.unref();
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
// First sample immediately.
|
|
81
|
-
poll();
|
|
82
|
-
}
|
|
83
|
-
stop() {
|
|
84
|
-
if (this._timer !== null) {
|
|
85
|
-
clearTimeout(this._timer);
|
|
86
|
-
this._timer = null;
|
|
87
|
-
}
|
|
88
|
-
this._stopGcObserver();
|
|
89
|
-
}
|
|
90
|
-
setCap(cap) {
|
|
91
|
-
this._cap = Math.max(10, Math.floor(cap) || 10);
|
|
92
|
-
while (this._samples.length > this._cap)
|
|
93
|
-
this._samples.shift();
|
|
94
|
-
}
|
|
95
|
-
query(since) {
|
|
96
|
-
if (!since)
|
|
97
|
-
return this._samples.slice();
|
|
98
|
-
return this._samples.filter(s => s.ts >= since);
|
|
99
|
-
}
|
|
100
|
-
/** Return Full (major) GC events for chart rendering.
|
|
101
|
-
* Pass since=0 to return ALL stored events (for full-mode fetches).
|
|
102
|
-
* Each returned event is one real full-GC cycle (phases already coalesced
|
|
103
|
-
* in `_startGcObserver`) and is normalized to kind=2 so the client can
|
|
104
|
-
* always treat `gcEvents` as "Full GC events". */
|
|
105
|
-
gcEvents(since) {
|
|
106
|
-
if (this._gcMajorEvents.length === 0)
|
|
107
|
-
return [];
|
|
108
|
-
const cutoff = since ?? (Date.now() - 5 * 60 * 1000);
|
|
109
|
-
return this._gcMajorEvents
|
|
110
|
-
.filter(e => e.ts >= cutoff)
|
|
111
|
-
.map(e => ({ ts: e.ts, kind: 2, duration: e.duration }))
|
|
112
|
-
.sort((a, b) => a.ts - b.ts);
|
|
113
|
-
}
|
|
114
|
-
summary(since) {
|
|
115
|
-
const data = this.query(since);
|
|
116
|
-
if (data.length === 0) {
|
|
117
|
-
return { current: null, peak: null, trend: 'stable', sampleCount: 0, heapRatio: 0, spanSeconds: 0, rssSlopePerMin: 0, gc: null };
|
|
118
|
-
}
|
|
119
|
-
const current = data[data.length - 1];
|
|
120
|
-
let peak = { heapUsed: 0, rss: 0, ts: 0 };
|
|
121
|
-
for (const s of data) {
|
|
122
|
-
if (s.heapUsed > peak.heapUsed)
|
|
123
|
-
peak = { heapUsed: s.heapUsed, rss: s.rss, ts: s.ts };
|
|
124
|
-
}
|
|
125
|
-
// Simple linear regression on heapUsed over the last 20 samples (or fewer).
|
|
126
|
-
const tail = data.slice(-20);
|
|
127
|
-
let trend = 'stable';
|
|
128
|
-
if (tail.length >= 3) {
|
|
129
|
-
const n = tail.length;
|
|
130
|
-
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
|
|
131
|
-
for (let i = 0; i < n; i++) {
|
|
132
|
-
sumX += i;
|
|
133
|
-
sumY += tail[i].heapUsed;
|
|
134
|
-
sumXY += i * tail[i].heapUsed;
|
|
135
|
-
sumXX += i * i;
|
|
136
|
-
}
|
|
137
|
-
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
|
138
|
-
// Threshold: ±0.5% of current heapUsed per sample-index step.
|
|
139
|
-
const threshold = current.heapUsed * 0.005;
|
|
140
|
-
if (slope > threshold)
|
|
141
|
-
trend = 'up';
|
|
142
|
-
else if (slope < -threshold)
|
|
143
|
-
trend = 'down';
|
|
144
|
-
}
|
|
145
|
-
const heapRatio = current.heapTotal > 0 ? current.heapUsed / current.heapTotal : 0;
|
|
146
|
-
const spanSeconds = data.length >= 2 ? Math.round((data[data.length - 1].ts - data[0].ts) / 1000) : 0;
|
|
147
|
-
// RSS linear regression over the last 20 samples → slope per minute (MB/min).
|
|
148
|
-
let rssSlopePerMin = 0;
|
|
149
|
-
if (tail.length >= 3) {
|
|
150
|
-
const n = tail.length;
|
|
151
|
-
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
|
|
152
|
-
for (let i = 0; i < n; i++) {
|
|
153
|
-
sumX += i;
|
|
154
|
-
sumY += tail[i].rss;
|
|
155
|
-
sumXY += i * tail[i].rss;
|
|
156
|
-
sumXX += i * i;
|
|
157
|
-
}
|
|
158
|
-
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
|
159
|
-
// slope = bytes per sample-index step. Convert to MB/min.
|
|
160
|
-
// Average interval between tail samples ≈ spanSeconds / (n-1).
|
|
161
|
-
const avgIntervalSec = tail.length >= 2 ? spanSeconds / (tail.length - 1) : 30;
|
|
162
|
-
const samplesPerMin = avgIntervalSec > 0 ? 60 / avgIntervalSec : 2;
|
|
163
|
-
rssSlopePerMin = Math.round((slope * samplesPerMin / 1048576) * 100) / 100;
|
|
164
|
-
}
|
|
165
|
-
// GC stats over the last ~5 minutes.
|
|
166
|
-
const gc = this._gcSummary(5 * 60 * 1000);
|
|
167
|
-
return { current, peak, trend, sampleCount: data.length, heapRatio, spanSeconds, rssSlopePerMin, gc };
|
|
168
|
-
}
|
|
169
|
-
/** Aggregate GC events in the last `windowMs` milliseconds. */
|
|
170
|
-
_gcSummary(windowMs) {
|
|
171
|
-
if (this._gcEvents.length === 0 && this._gcMajorEvents.length === 0)
|
|
172
|
-
return null;
|
|
173
|
-
const cutoff = Date.now() - windowMs;
|
|
174
|
-
// Major (Full) count comes from the coalesced major-GC buffer so each
|
|
175
|
-
// real cycle counts once (not once per phase entry).
|
|
176
|
-
const majorRecent = this._gcMajorEvents.filter(e => e.ts >= cutoff);
|
|
177
|
-
const majorCount = majorRecent.length;
|
|
178
|
-
// Minor count + total pause come from the general ring buffer (which holds
|
|
179
|
-
// every event). A minor cycle is a Scavenge (kind bit 1 set).
|
|
180
|
-
let minorCount = 0, gcPauseMs = 0;
|
|
181
|
-
for (const e of this._gcEvents) {
|
|
182
|
-
if (e.ts < cutoff)
|
|
183
|
-
continue;
|
|
184
|
-
gcPauseMs += e.duration;
|
|
185
|
-
if ((e.kind & 1) !== 0)
|
|
186
|
-
minorCount++;
|
|
187
|
-
}
|
|
188
|
-
if (majorCount === 0 && minorCount === 0 && gcPauseMs === 0)
|
|
189
|
-
return null;
|
|
190
|
-
const windowMin = windowMs / 60000;
|
|
191
|
-
return {
|
|
192
|
-
minorCount,
|
|
193
|
-
majorCount,
|
|
194
|
-
majorPerMin: Math.round((majorCount / windowMin) * 100) / 100,
|
|
195
|
-
gcPauseMs: Math.round(gcPauseMs * 100) / 100,
|
|
196
|
-
gcPausePerMin: Math.round((gcPauseMs / windowMin) * 100) / 100,
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
async _startGcObserver() {
|
|
200
|
-
try {
|
|
201
|
-
// Dynamic import — perf_hooks may not be available in all environments.
|
|
202
|
-
// MUST use import(), not require(): this half is ESM, so require() throws
|
|
203
|
-
// ReferenceError into the catch — silently disabling GC monitoring forever.
|
|
204
|
-
const { PerformanceObserver } = await import('perf_hooks');
|
|
205
|
-
this._gcObserver = new PerformanceObserver((list) => {
|
|
206
|
-
for (const entry of list.getEntries()) {
|
|
207
|
-
// GC entry kinds form a bitmask (see isFullGc): 1=Scavenge(minor),
|
|
208
|
-
// 2/4/8 = MarkSweepCompact/IncrementalMarking/ProcessWeakCallbacks.
|
|
209
|
-
// A single real Full GC is usually reported as several phase entries
|
|
210
|
-
// (e.g. kind 4 + kind 8) within a few ms, so we coalesce them into
|
|
211
|
-
// one Full GC event below.
|
|
212
|
-
const kind = entry.kind ?? 0;
|
|
213
|
-
const event = { ts: Date.now(), kind, duration: entry.duration };
|
|
214
|
-
this._gcEvents.push(event);
|
|
215
|
-
// Also store full (major) GC events in a separate long-lived buffer,
|
|
216
|
-
// because they are rare and the general ring buffer may evict them.
|
|
217
|
-
if (isFullGc(kind)) {
|
|
218
|
-
const last = this._gcMajorEvents[this._gcMajorEvents.length - 1];
|
|
219
|
-
if (last && event.ts - last.ts < 500) {
|
|
220
|
-
// Same full-GC cycle (phase entries arriving back-to-back):
|
|
221
|
-
// merge into the existing event, keeping the worst-case pause.
|
|
222
|
-
last.duration = Math.max(last.duration, event.duration);
|
|
223
|
-
last.ts = event.ts;
|
|
224
|
-
}
|
|
225
|
-
else {
|
|
226
|
-
this._gcMajorEvents.push(event);
|
|
227
|
-
while (this._gcMajorEvents.length > this._gcMajorEventsCap)
|
|
228
|
-
this._gcMajorEvents.shift();
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
// Cap the ring buffer.
|
|
233
|
-
while (this._gcEvents.length > this._gcEventsCap)
|
|
234
|
-
this._gcEvents.shift();
|
|
235
|
-
});
|
|
236
|
-
this._gcObserver.observe({ type: 'gc', buffered: true });
|
|
237
|
-
}
|
|
238
|
-
catch (_) {
|
|
239
|
-
// GC observation not available — gc will remain null in summaries.
|
|
240
|
-
this._gcObserver = null;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
_stopGcObserver() {
|
|
244
|
-
if (this._gcObserver) {
|
|
245
|
-
try {
|
|
246
|
-
this._gcObserver.disconnect();
|
|
247
|
-
}
|
|
248
|
-
catch (_) { }
|
|
249
|
-
this._gcObserver = null;
|
|
250
|
-
}
|
|
251
|
-
this._gcEvents = [];
|
|
252
|
-
this._gcMajorEvents = [];
|
|
253
|
-
}
|
|
254
|
-
_push(s) {
|
|
255
|
-
this._samples.push(s);
|
|
256
|
-
if (this._samples.length > this._cap)
|
|
257
|
-
this._samples.shift();
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
/** Module-level singleton — one collector per process. */
|
|
261
|
-
let _memoryTrend = null;
|
|
262
|
-
// ── Private HTTP helpers ────────────────────────────────────────────────
|
|
263
|
-
/** Send a JSON response with no-store cache control. */
|
|
264
|
-
function sendJson(res, status, payload) {
|
|
265
|
-
res.statusCode = status;
|
|
266
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
267
|
-
res.setHeader('cache-control', 'no-store');
|
|
268
|
-
res.end(JSON.stringify(payload));
|
|
269
|
-
}
|
|
270
|
-
/**
|
|
271
|
-
* Read an optional JSON request body, bounded so a client cannot feed the
|
|
272
|
-
* host an unbounded buffer. Returns null for an empty, oversized, or
|
|
273
|
-
* unparseable body — callers treat that as "no override supplied".
|
|
274
|
-
*/
|
|
275
|
-
async function readJsonBody(req, limit = 4096) {
|
|
276
|
-
try {
|
|
277
|
-
const chunks = [];
|
|
278
|
-
let size = 0;
|
|
279
|
-
for await (const chunk of req) {
|
|
280
|
-
size += chunk.length;
|
|
281
|
-
if (size > limit)
|
|
282
|
-
return null;
|
|
283
|
-
chunks.push(chunk);
|
|
284
|
-
}
|
|
285
|
-
if (chunks.length === 0)
|
|
286
|
-
return null;
|
|
287
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
288
|
-
}
|
|
289
|
-
catch (_) {
|
|
290
|
-
return null;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
// ── apply() ─────────────────────────────────────────────────────────────
|
|
294
|
-
export function apply(ctx, config) {
|
|
295
|
-
// ── Settings namespace registration ──────────────────────────────────
|
|
296
|
-
ctx.inject(['settings'], (settingsCtx) => {
|
|
297
|
-
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber));
|
|
298
|
-
});
|
|
299
|
-
// ── Memory trend collector bootstrap ──────────────────────────────────
|
|
300
|
-
// One bounded ring buffer per process. Starts on first apply and stops on
|
|
301
|
-
// dispose. The timer uses .unref() so it never keeps the process alive.
|
|
302
|
-
if (!_memoryTrend)
|
|
303
|
-
_memoryTrend = new MemoryTrendCollector();
|
|
304
|
-
_memoryTrend.start((config.memPollBase?.get?.() ?? config.memPollBase) || DEFAULT_MEM_POLL_BASE, (config.memPollMin?.get?.() ?? config.memPollMin) || DEFAULT_MEM_POLL_MIN);
|
|
305
|
-
ctx.effect(() => {
|
|
306
|
-
const collector = _memoryTrend;
|
|
307
|
-
return () => {
|
|
308
|
-
collector?.stop();
|
|
309
|
-
_memoryTrend = null;
|
|
310
|
-
};
|
|
311
|
-
});
|
|
312
|
-
// ── Volatile-update handler for poll intervals ────────────────────────
|
|
313
|
-
// Restart the collector when memPollBase or memPollMin change.
|
|
314
|
-
ctx.on('loader/volatile-update', (paths) => {
|
|
315
|
-
const memPaths = ['memPollBase', 'memPollMin'];
|
|
316
|
-
if (!paths.some((pp) => pp.length && memPaths.includes(pp[pp.length - 1])))
|
|
317
|
-
return;
|
|
318
|
-
if (_memoryTrend) {
|
|
319
|
-
_memoryTrend.stop();
|
|
320
|
-
_memoryTrend.start((config.memPollBase?.get?.() ?? config.memPollBase) || DEFAULT_MEM_POLL_BASE, (config.memPollMin?.get?.() ?? config.memPollMin) || DEFAULT_MEM_POLL_MIN);
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
// ── HTTP API route for client-side trend data ────────────────────────
|
|
324
|
-
// The webServer type augmentation lives in @deepseek-ai/dsh-host-webserver
|
|
325
|
-
// which is not a direct dependency; cast through `any` for the register calls.
|
|
326
|
-
ctx.inject(['webServer'], (wsCtx) => {
|
|
327
|
-
// GET /plugins/dsh-flash-mem-mon/memory-trend
|
|
328
|
-
// Returns memory trend data for the client's config popup.
|
|
329
|
-
// `mode=summary` returns a lightweight snapshot (current, peak, trend direction);
|
|
330
|
-
// `mode=full` returns the raw samples for rendering a chart.
|
|
331
|
-
// Optional `since` (unix ms) limits the range.
|
|
332
|
-
wsCtx.effect(() => wsCtx.webServer.register({
|
|
333
|
-
kind: 'exact',
|
|
334
|
-
path: '/plugins/dsh-flash-mem-mon/memory-trend',
|
|
335
|
-
handler: async (req, res) => {
|
|
336
|
-
if (req.method !== 'GET') {
|
|
337
|
-
res.statusCode = 405;
|
|
338
|
-
res.setHeader('allow', 'GET');
|
|
339
|
-
res.end();
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
if (!_memoryTrend) {
|
|
343
|
-
sendJson(res, 200, { samples: [], summary: null });
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
const u = new URL(req.url || '/', 'http://localhost');
|
|
347
|
-
const since = parseInt(u.searchParams.get('since') || '0', 10) || 0;
|
|
348
|
-
const mode = u.searchParams.get('mode') || 'summary';
|
|
349
|
-
if (mode === 'full') {
|
|
350
|
-
// For gcEvents: when no explicit `since` is requested (since=0),
|
|
351
|
-
// pass 0 so ALL stored events are returned — not just the last 5 min.
|
|
352
|
-
// gcEvents(0) → cutoff=0 → returns everything.
|
|
353
|
-
// gcEvents(undefined) → cutoff=now-5min → only recent events.
|
|
354
|
-
// The sparkline may span hours, so we need the full range.
|
|
355
|
-
const gcSince = since > 0 ? since : 0;
|
|
356
|
-
// Only return full (major) GC events to the client — minor GC is too
|
|
357
|
-
// frequent and clutters the chart. These are already coalesced into
|
|
358
|
-
// one event per real cycle by `_startGcObserver`. Minor GC stats are
|
|
359
|
-
// still available via mode=summary (majorPerMin, gcPausePerMin).
|
|
360
|
-
const majorGcEvents = _memoryTrend.gcEvents(gcSince);
|
|
361
|
-
sendJson(res, 200, {
|
|
362
|
-
samples: _memoryTrend.query(since || undefined),
|
|
363
|
-
gcEvents: majorGcEvents,
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
else {
|
|
367
|
-
sendJson(res, 200, _memoryTrend.summary(since || undefined));
|
|
368
|
-
}
|
|
369
|
-
},
|
|
370
|
-
}), 'dsh-flash-mem-mon: GET /plugins/dsh-flash-mem-mon/memory-trend');
|
|
371
|
-
});
|
|
372
|
-
}
|
|
1
|
+
// Default export only (`export default Schema`); there is no named `Schema`.
|
|
2
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
3
|
+
export const name = 'dsh-flash-mem-mon';
|
|
4
|
+
// No host-side service dependencies; all services are injected lazily.
|
|
5
|
+
export const inject = [];
|
|
6
|
+
// ── Memory alert thresholds (RSS MB) and GC thresholds (major GC/min) ──
|
|
7
|
+
const DEFAULT_MEM_THRESHOLD_INFO = 256;
|
|
8
|
+
const DEFAULT_MEM_THRESHOLD_WARNING = 512;
|
|
9
|
+
const DEFAULT_MEM_THRESHOLD_ERROR = 1024;
|
|
10
|
+
const DEFAULT_MEM_THRESHOLD_CRITICAL = 1536;
|
|
11
|
+
const DEFAULT_MEM_POLL_BASE = 30000;
|
|
12
|
+
const DEFAULT_MEM_POLL_MIN = 2000;
|
|
13
|
+
const DEFAULT_GC_THRESHOLD_INFO = 2;
|
|
14
|
+
const DEFAULT_GC_THRESHOLD_WARNING = 5;
|
|
15
|
+
const DEFAULT_GC_THRESHOLD_ERROR = 10;
|
|
16
|
+
// V8 GC entry `kind` is a bitmask of GC types:
|
|
17
|
+
// 1 << 0 = Scavenge (minor / young-gen)
|
|
18
|
+
// 1 << 1 = MarkSweepCompact (major / full)
|
|
19
|
+
// 1 << 2 = IncrementalMarking
|
|
20
|
+
// 1 << 3 = ProcessWeakCallbacks
|
|
21
|
+
// In practice modern V8/Node reports a full MarkSweepCompact cycle through the
|
|
22
|
+
// IncrementalMarking (4) and ProcessWeakCallbacks (8) phases — the standalone
|
|
23
|
+
// "major" bit (2) is almost never emitted on its own. Classifying Full GC by
|
|
24
|
+
// `kind === 2` alone therefore misses (nearly) every real full collection.
|
|
25
|
+
// The robust test: a Full GC is any NON-scavenge cycle, i.e. the Scavenge bit
|
|
26
|
+
// (1) is NOT set. This captures 2, 4, 6, 8, 10, 12, 14 while excluding minor
|
|
27
|
+
// (1) scavenges.
|
|
28
|
+
function isFullGc(kind) {
|
|
29
|
+
return (kind & 1) === 0 && kind > 0;
|
|
30
|
+
}
|
|
31
|
+
export const Config = Schema.object({
|
|
32
|
+
memThresholdInfo: Schema.number().default(DEFAULT_MEM_THRESHOLD_INFO).volatile(),
|
|
33
|
+
memThresholdWarning: Schema.number().default(DEFAULT_MEM_THRESHOLD_WARNING).volatile(),
|
|
34
|
+
memThresholdError: Schema.number().default(DEFAULT_MEM_THRESHOLD_ERROR).volatile(),
|
|
35
|
+
memThresholdCritical: Schema.number().default(DEFAULT_MEM_THRESHOLD_CRITICAL).volatile(),
|
|
36
|
+
memPollBase: Schema.number().default(DEFAULT_MEM_POLL_BASE).volatile(),
|
|
37
|
+
memPollMin: Schema.number().default(DEFAULT_MEM_POLL_MIN).volatile(),
|
|
38
|
+
gcThresholdInfo: Schema.number().default(DEFAULT_GC_THRESHOLD_INFO).volatile(),
|
|
39
|
+
gcThresholdWarning: Schema.number().default(DEFAULT_GC_THRESHOLD_WARNING).volatile(),
|
|
40
|
+
gcThresholdError: Schema.number().default(DEFAULT_GC_THRESHOLD_ERROR).volatile(),
|
|
41
|
+
});
|
|
42
|
+
class MemoryTrendCollector {
|
|
43
|
+
_samples = [];
|
|
44
|
+
_timer = null;
|
|
45
|
+
_cap;
|
|
46
|
+
// GC monitoring via PerformanceObserver
|
|
47
|
+
_gcObserver = null; // PerformanceObserver | null
|
|
48
|
+
_gcEvents = [];
|
|
49
|
+
_gcEventsCap = 2000; // ~5 min at worst-case GC frequency
|
|
50
|
+
/** Separate cap for major GC events only — they are rare and valuable for diagnostics. */
|
|
51
|
+
_gcMajorEvents = [];
|
|
52
|
+
_gcMajorEventsCap = 500; // Full GC events are rare; keep up to 500 (~hours)
|
|
53
|
+
constructor(cap = 720) {
|
|
54
|
+
this._cap = Math.max(10, cap);
|
|
55
|
+
}
|
|
56
|
+
start(baseInterval = 30_000, minInterval = 5_000) {
|
|
57
|
+
this.stop();
|
|
58
|
+
// Fire-and-forget: GC observer can start asynchronously; the poll loop
|
|
59
|
+
// below begins immediately regardless, since it does not depend on GC.
|
|
60
|
+
this._startGcObserver().catch(() => { });
|
|
61
|
+
const poll = () => {
|
|
62
|
+
const mu = process.memoryUsage();
|
|
63
|
+
this._push({
|
|
64
|
+
ts: Date.now(),
|
|
65
|
+
rss: mu.rss,
|
|
66
|
+
heapTotal: mu.heapTotal,
|
|
67
|
+
heapUsed: mu.heapUsed,
|
|
68
|
+
external: mu.external,
|
|
69
|
+
arrayBuffers: mu.arrayBuffers,
|
|
70
|
+
});
|
|
71
|
+
// Adaptive: higher heap usage → shorter interval (sample more densely).
|
|
72
|
+
const ratio = mu.heapUsed / mu.heapTotal;
|
|
73
|
+
const next = Math.max(minInterval, Math.round(baseInterval * Math.pow(1 - Math.min(ratio, 1), 2) + minInterval));
|
|
74
|
+
this._timer = setTimeout(poll, next);
|
|
75
|
+
// Unref so the timer never keeps the process alive on its own.
|
|
76
|
+
if (this._timer && typeof this._timer === 'object' && 'unref' in this._timer) {
|
|
77
|
+
this._timer.unref();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
// First sample immediately.
|
|
81
|
+
poll();
|
|
82
|
+
}
|
|
83
|
+
stop() {
|
|
84
|
+
if (this._timer !== null) {
|
|
85
|
+
clearTimeout(this._timer);
|
|
86
|
+
this._timer = null;
|
|
87
|
+
}
|
|
88
|
+
this._stopGcObserver();
|
|
89
|
+
}
|
|
90
|
+
setCap(cap) {
|
|
91
|
+
this._cap = Math.max(10, Math.floor(cap) || 10);
|
|
92
|
+
while (this._samples.length > this._cap)
|
|
93
|
+
this._samples.shift();
|
|
94
|
+
}
|
|
95
|
+
query(since) {
|
|
96
|
+
if (!since)
|
|
97
|
+
return this._samples.slice();
|
|
98
|
+
return this._samples.filter(s => s.ts >= since);
|
|
99
|
+
}
|
|
100
|
+
/** Return Full (major) GC events for chart rendering.
|
|
101
|
+
* Pass since=0 to return ALL stored events (for full-mode fetches).
|
|
102
|
+
* Each returned event is one real full-GC cycle (phases already coalesced
|
|
103
|
+
* in `_startGcObserver`) and is normalized to kind=2 so the client can
|
|
104
|
+
* always treat `gcEvents` as "Full GC events". */
|
|
105
|
+
gcEvents(since) {
|
|
106
|
+
if (this._gcMajorEvents.length === 0)
|
|
107
|
+
return [];
|
|
108
|
+
const cutoff = since ?? (Date.now() - 5 * 60 * 1000);
|
|
109
|
+
return this._gcMajorEvents
|
|
110
|
+
.filter(e => e.ts >= cutoff)
|
|
111
|
+
.map(e => ({ ts: e.ts, kind: 2, duration: e.duration }))
|
|
112
|
+
.sort((a, b) => a.ts - b.ts);
|
|
113
|
+
}
|
|
114
|
+
summary(since) {
|
|
115
|
+
const data = this.query(since);
|
|
116
|
+
if (data.length === 0) {
|
|
117
|
+
return { current: null, peak: null, trend: 'stable', sampleCount: 0, heapRatio: 0, spanSeconds: 0, rssSlopePerMin: 0, gc: null };
|
|
118
|
+
}
|
|
119
|
+
const current = data[data.length - 1];
|
|
120
|
+
let peak = { heapUsed: 0, rss: 0, ts: 0 };
|
|
121
|
+
for (const s of data) {
|
|
122
|
+
if (s.heapUsed > peak.heapUsed)
|
|
123
|
+
peak = { heapUsed: s.heapUsed, rss: s.rss, ts: s.ts };
|
|
124
|
+
}
|
|
125
|
+
// Simple linear regression on heapUsed over the last 20 samples (or fewer).
|
|
126
|
+
const tail = data.slice(-20);
|
|
127
|
+
let trend = 'stable';
|
|
128
|
+
if (tail.length >= 3) {
|
|
129
|
+
const n = tail.length;
|
|
130
|
+
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
|
|
131
|
+
for (let i = 0; i < n; i++) {
|
|
132
|
+
sumX += i;
|
|
133
|
+
sumY += tail[i].heapUsed;
|
|
134
|
+
sumXY += i * tail[i].heapUsed;
|
|
135
|
+
sumXX += i * i;
|
|
136
|
+
}
|
|
137
|
+
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
|
138
|
+
// Threshold: ±0.5% of current heapUsed per sample-index step.
|
|
139
|
+
const threshold = current.heapUsed * 0.005;
|
|
140
|
+
if (slope > threshold)
|
|
141
|
+
trend = 'up';
|
|
142
|
+
else if (slope < -threshold)
|
|
143
|
+
trend = 'down';
|
|
144
|
+
}
|
|
145
|
+
const heapRatio = current.heapTotal > 0 ? current.heapUsed / current.heapTotal : 0;
|
|
146
|
+
const spanSeconds = data.length >= 2 ? Math.round((data[data.length - 1].ts - data[0].ts) / 1000) : 0;
|
|
147
|
+
// RSS linear regression over the last 20 samples → slope per minute (MB/min).
|
|
148
|
+
let rssSlopePerMin = 0;
|
|
149
|
+
if (tail.length >= 3) {
|
|
150
|
+
const n = tail.length;
|
|
151
|
+
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
|
|
152
|
+
for (let i = 0; i < n; i++) {
|
|
153
|
+
sumX += i;
|
|
154
|
+
sumY += tail[i].rss;
|
|
155
|
+
sumXY += i * tail[i].rss;
|
|
156
|
+
sumXX += i * i;
|
|
157
|
+
}
|
|
158
|
+
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
|
159
|
+
// slope = bytes per sample-index step. Convert to MB/min.
|
|
160
|
+
// Average interval between tail samples ≈ spanSeconds / (n-1).
|
|
161
|
+
const avgIntervalSec = tail.length >= 2 ? spanSeconds / (tail.length - 1) : 30;
|
|
162
|
+
const samplesPerMin = avgIntervalSec > 0 ? 60 / avgIntervalSec : 2;
|
|
163
|
+
rssSlopePerMin = Math.round((slope * samplesPerMin / 1048576) * 100) / 100;
|
|
164
|
+
}
|
|
165
|
+
// GC stats over the last ~5 minutes.
|
|
166
|
+
const gc = this._gcSummary(5 * 60 * 1000);
|
|
167
|
+
return { current, peak, trend, sampleCount: data.length, heapRatio, spanSeconds, rssSlopePerMin, gc };
|
|
168
|
+
}
|
|
169
|
+
/** Aggregate GC events in the last `windowMs` milliseconds. */
|
|
170
|
+
_gcSummary(windowMs) {
|
|
171
|
+
if (this._gcEvents.length === 0 && this._gcMajorEvents.length === 0)
|
|
172
|
+
return null;
|
|
173
|
+
const cutoff = Date.now() - windowMs;
|
|
174
|
+
// Major (Full) count comes from the coalesced major-GC buffer so each
|
|
175
|
+
// real cycle counts once (not once per phase entry).
|
|
176
|
+
const majorRecent = this._gcMajorEvents.filter(e => e.ts >= cutoff);
|
|
177
|
+
const majorCount = majorRecent.length;
|
|
178
|
+
// Minor count + total pause come from the general ring buffer (which holds
|
|
179
|
+
// every event). A minor cycle is a Scavenge (kind bit 1 set).
|
|
180
|
+
let minorCount = 0, gcPauseMs = 0;
|
|
181
|
+
for (const e of this._gcEvents) {
|
|
182
|
+
if (e.ts < cutoff)
|
|
183
|
+
continue;
|
|
184
|
+
gcPauseMs += e.duration;
|
|
185
|
+
if ((e.kind & 1) !== 0)
|
|
186
|
+
minorCount++;
|
|
187
|
+
}
|
|
188
|
+
if (majorCount === 0 && minorCount === 0 && gcPauseMs === 0)
|
|
189
|
+
return null;
|
|
190
|
+
const windowMin = windowMs / 60000;
|
|
191
|
+
return {
|
|
192
|
+
minorCount,
|
|
193
|
+
majorCount,
|
|
194
|
+
majorPerMin: Math.round((majorCount / windowMin) * 100) / 100,
|
|
195
|
+
gcPauseMs: Math.round(gcPauseMs * 100) / 100,
|
|
196
|
+
gcPausePerMin: Math.round((gcPauseMs / windowMin) * 100) / 100,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async _startGcObserver() {
|
|
200
|
+
try {
|
|
201
|
+
// Dynamic import — perf_hooks may not be available in all environments.
|
|
202
|
+
// MUST use import(), not require(): this half is ESM, so require() throws
|
|
203
|
+
// ReferenceError into the catch — silently disabling GC monitoring forever.
|
|
204
|
+
const { PerformanceObserver } = await import('perf_hooks');
|
|
205
|
+
this._gcObserver = new PerformanceObserver((list) => {
|
|
206
|
+
for (const entry of list.getEntries()) {
|
|
207
|
+
// GC entry kinds form a bitmask (see isFullGc): 1=Scavenge(minor),
|
|
208
|
+
// 2/4/8 = MarkSweepCompact/IncrementalMarking/ProcessWeakCallbacks.
|
|
209
|
+
// A single real Full GC is usually reported as several phase entries
|
|
210
|
+
// (e.g. kind 4 + kind 8) within a few ms, so we coalesce them into
|
|
211
|
+
// one Full GC event below.
|
|
212
|
+
const kind = entry.kind ?? 0;
|
|
213
|
+
const event = { ts: Date.now(), kind, duration: entry.duration };
|
|
214
|
+
this._gcEvents.push(event);
|
|
215
|
+
// Also store full (major) GC events in a separate long-lived buffer,
|
|
216
|
+
// because they are rare and the general ring buffer may evict them.
|
|
217
|
+
if (isFullGc(kind)) {
|
|
218
|
+
const last = this._gcMajorEvents[this._gcMajorEvents.length - 1];
|
|
219
|
+
if (last && event.ts - last.ts < 500) {
|
|
220
|
+
// Same full-GC cycle (phase entries arriving back-to-back):
|
|
221
|
+
// merge into the existing event, keeping the worst-case pause.
|
|
222
|
+
last.duration = Math.max(last.duration, event.duration);
|
|
223
|
+
last.ts = event.ts;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
this._gcMajorEvents.push(event);
|
|
227
|
+
while (this._gcMajorEvents.length > this._gcMajorEventsCap)
|
|
228
|
+
this._gcMajorEvents.shift();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Cap the ring buffer.
|
|
233
|
+
while (this._gcEvents.length > this._gcEventsCap)
|
|
234
|
+
this._gcEvents.shift();
|
|
235
|
+
});
|
|
236
|
+
this._gcObserver.observe({ type: 'gc', buffered: true });
|
|
237
|
+
}
|
|
238
|
+
catch (_) {
|
|
239
|
+
// GC observation not available — gc will remain null in summaries.
|
|
240
|
+
this._gcObserver = null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
_stopGcObserver() {
|
|
244
|
+
if (this._gcObserver) {
|
|
245
|
+
try {
|
|
246
|
+
this._gcObserver.disconnect();
|
|
247
|
+
}
|
|
248
|
+
catch (_) { }
|
|
249
|
+
this._gcObserver = null;
|
|
250
|
+
}
|
|
251
|
+
this._gcEvents = [];
|
|
252
|
+
this._gcMajorEvents = [];
|
|
253
|
+
}
|
|
254
|
+
_push(s) {
|
|
255
|
+
this._samples.push(s);
|
|
256
|
+
if (this._samples.length > this._cap)
|
|
257
|
+
this._samples.shift();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Module-level singleton — one collector per process. */
|
|
261
|
+
let _memoryTrend = null;
|
|
262
|
+
// ── Private HTTP helpers ────────────────────────────────────────────────
|
|
263
|
+
/** Send a JSON response with no-store cache control. */
|
|
264
|
+
function sendJson(res, status, payload) {
|
|
265
|
+
res.statusCode = status;
|
|
266
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
267
|
+
res.setHeader('cache-control', 'no-store');
|
|
268
|
+
res.end(JSON.stringify(payload));
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Read an optional JSON request body, bounded so a client cannot feed the
|
|
272
|
+
* host an unbounded buffer. Returns null for an empty, oversized, or
|
|
273
|
+
* unparseable body — callers treat that as "no override supplied".
|
|
274
|
+
*/
|
|
275
|
+
async function readJsonBody(req, limit = 4096) {
|
|
276
|
+
try {
|
|
277
|
+
const chunks = [];
|
|
278
|
+
let size = 0;
|
|
279
|
+
for await (const chunk of req) {
|
|
280
|
+
size += chunk.length;
|
|
281
|
+
if (size > limit)
|
|
282
|
+
return null;
|
|
283
|
+
chunks.push(chunk);
|
|
284
|
+
}
|
|
285
|
+
if (chunks.length === 0)
|
|
286
|
+
return null;
|
|
287
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
288
|
+
}
|
|
289
|
+
catch (_) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// ── apply() ─────────────────────────────────────────────────────────────
|
|
294
|
+
export function apply(ctx, config) {
|
|
295
|
+
// ── Settings namespace registration ──────────────────────────────────
|
|
296
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
297
|
+
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber));
|
|
298
|
+
});
|
|
299
|
+
// ── Memory trend collector bootstrap ──────────────────────────────────
|
|
300
|
+
// One bounded ring buffer per process. Starts on first apply and stops on
|
|
301
|
+
// dispose. The timer uses .unref() so it never keeps the process alive.
|
|
302
|
+
if (!_memoryTrend)
|
|
303
|
+
_memoryTrend = new MemoryTrendCollector();
|
|
304
|
+
_memoryTrend.start((config.memPollBase?.get?.() ?? config.memPollBase) || DEFAULT_MEM_POLL_BASE, (config.memPollMin?.get?.() ?? config.memPollMin) || DEFAULT_MEM_POLL_MIN);
|
|
305
|
+
ctx.effect(() => {
|
|
306
|
+
const collector = _memoryTrend;
|
|
307
|
+
return () => {
|
|
308
|
+
collector?.stop();
|
|
309
|
+
_memoryTrend = null;
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
// ── Volatile-update handler for poll intervals ────────────────────────
|
|
313
|
+
// Restart the collector when memPollBase or memPollMin change.
|
|
314
|
+
ctx.on('loader/volatile-update', (paths) => {
|
|
315
|
+
const memPaths = ['memPollBase', 'memPollMin'];
|
|
316
|
+
if (!paths.some((pp) => pp.length && memPaths.includes(pp[pp.length - 1])))
|
|
317
|
+
return;
|
|
318
|
+
if (_memoryTrend) {
|
|
319
|
+
_memoryTrend.stop();
|
|
320
|
+
_memoryTrend.start((config.memPollBase?.get?.() ?? config.memPollBase) || DEFAULT_MEM_POLL_BASE, (config.memPollMin?.get?.() ?? config.memPollMin) || DEFAULT_MEM_POLL_MIN);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
// ── HTTP API route for client-side trend data ────────────────────────
|
|
324
|
+
// The webServer type augmentation lives in @deepseek-ai/dsh-host-webserver
|
|
325
|
+
// which is not a direct dependency; cast through `any` for the register calls.
|
|
326
|
+
ctx.inject(['webServer'], (wsCtx) => {
|
|
327
|
+
// GET /plugins/dsh-flash-mem-mon/memory-trend
|
|
328
|
+
// Returns memory trend data for the client's config popup.
|
|
329
|
+
// `mode=summary` returns a lightweight snapshot (current, peak, trend direction);
|
|
330
|
+
// `mode=full` returns the raw samples for rendering a chart.
|
|
331
|
+
// Optional `since` (unix ms) limits the range.
|
|
332
|
+
wsCtx.effect(() => wsCtx.webServer.register({
|
|
333
|
+
kind: 'exact',
|
|
334
|
+
path: '/plugins/dsh-flash-mem-mon/memory-trend',
|
|
335
|
+
handler: async (req, res) => {
|
|
336
|
+
if (req.method !== 'GET') {
|
|
337
|
+
res.statusCode = 405;
|
|
338
|
+
res.setHeader('allow', 'GET');
|
|
339
|
+
res.end();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (!_memoryTrend) {
|
|
343
|
+
sendJson(res, 200, { samples: [], summary: null });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const u = new URL(req.url || '/', 'http://localhost');
|
|
347
|
+
const since = parseInt(u.searchParams.get('since') || '0', 10) || 0;
|
|
348
|
+
const mode = u.searchParams.get('mode') || 'summary';
|
|
349
|
+
if (mode === 'full') {
|
|
350
|
+
// For gcEvents: when no explicit `since` is requested (since=0),
|
|
351
|
+
// pass 0 so ALL stored events are returned — not just the last 5 min.
|
|
352
|
+
// gcEvents(0) → cutoff=0 → returns everything.
|
|
353
|
+
// gcEvents(undefined) → cutoff=now-5min → only recent events.
|
|
354
|
+
// The sparkline may span hours, so we need the full range.
|
|
355
|
+
const gcSince = since > 0 ? since : 0;
|
|
356
|
+
// Only return full (major) GC events to the client — minor GC is too
|
|
357
|
+
// frequent and clutters the chart. These are already coalesced into
|
|
358
|
+
// one event per real cycle by `_startGcObserver`. Minor GC stats are
|
|
359
|
+
// still available via mode=summary (majorPerMin, gcPausePerMin).
|
|
360
|
+
const majorGcEvents = _memoryTrend.gcEvents(gcSince);
|
|
361
|
+
sendJson(res, 200, {
|
|
362
|
+
samples: _memoryTrend.query(since || undefined),
|
|
363
|
+
gcEvents: majorGcEvents,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
sendJson(res, 200, _memoryTrend.summary(since || undefined));
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
}), 'dsh-flash-mem-mon: GET /plugins/dsh-flash-mem-mon/memory-trend');
|
|
371
|
+
});
|
|
372
|
+
}
|
package/package.json
CHANGED
|
@@ -1,45 +1,50 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-flash-mem-mon",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Memory monitor provider for
|
|
5
|
-
"license": "Apache-2.0",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"main": "./dist/index.js",
|
|
8
|
-
"exports": {
|
|
9
|
-
".": "./dist/index.js",
|
|
10
|
-
"./client": "./lib/client.js",
|
|
11
|
-
"./package.json": "./package.json"
|
|
12
|
-
},
|
|
13
|
-
"files": ["dist", "lib", "cordis.patch.yml", "README.md", "README.zh-CN.md"],
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-flash-mem-mon",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Memory monitor provider for the `dsh-flash` Quick Control panel — registers a host-memory-alert provider (RSS absolute, RSS growth rate, Major GC frequency) via ctx.get('dockFlashAlerts') and exposes /memory-trend for trend sparklines.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./client": "./lib/client.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": ["dist", "lib", "cordis.patch.yml", "README.md", "README.zh-CN.md"],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/tcgbp/dsh-flash-mem-mon.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/tcgbp/dsh-flash-mem-mon#readme",
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
21
|
+
"client": {
|
|
22
|
+
"inject": [
|
|
23
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
24
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
25
|
+
"dsh-flash",
|
|
26
|
+
"dock-base"
|
|
27
|
+
],
|
|
28
|
+
"platform": "web"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.json",
|
|
33
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": { "@deepseek-ai/schemastery": "^3.18.4" },
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@deepseek-ai/cordis": ">=4.0.0-rc.1 <5.0.0-0 || >=4.0.1-0 <5.0.0-0",
|
|
38
|
+
"dock-base": ">=0.1.2-0 <1.0.0-0 || >=0.2.0-0 <1.0.0-0",
|
|
39
|
+
"dsh-flash": ">=1.0.0-0 <2.0.0-0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"dock-base": { "optional": true }
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
46
|
+
"@deepseek-ai/dsh-settings": "0.1.7-rc.2",
|
|
47
|
+
"@types/node": "^22.10.0",
|
|
48
|
+
"typescript": "^5.6.0"
|
|
49
|
+
}
|
|
50
|
+
}
|