dsh-flash-mem-mon 0.1.7 → 0.2.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-flash-mem-mon
2
2
 
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.
3
+ Performance monitor provider for [dsh-flash](https://github.com/tcgbp/dsh-flash) — registers a host-performance-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
 
package/README.zh-CN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-flash-mem-mon
2
2
 
3
- dsh-flash 的内存监控提供器 — 通过 `ctx.get('dockFlashAlerts')` 注册主机内存告警提供器(RSS 绝对阈值、RSS 增长率、Major GC 频率),并暴露 `/memory-trend` 路由用于趋势图。
3
+ dsh-flash 的性能监控提供器 — 通过 `ctx.get('dockFlashAlerts')` 注册主机性能告警提供器(RSS 绝对阈值、RSS 增长率、Major GC 频率),并暴露 `/memory-trend` 路由用于趋势图。
4
4
 
5
5
  ## 安装
6
6
 
package/dist/index.js CHANGED
@@ -1,9 +1,16 @@
1
+ import { writeHeapSnapshot } from 'node:v8';
2
+ import { createReadStream } from 'node:fs';
3
+ import { readdir, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { homedir, tmpdir } from 'node:os';
1
6
  // Default export only (`export default Schema`); there is no named `Schema`.
2
7
  import Schema from '@deepseek-ai/schemastery';
3
- import { DEFAULT_ALERT_CONFIG, isFullGc, rssSlopePerMin } from './pure.js';
8
+ import { countArchivedSessions, DEFAULT_ALERT_CONFIG, formatBytes, isFullGc, rssSlopePerMin, sumBytes, } from './pure.js';
4
9
  export const name = 'dsh-flash-mem-mon';
5
- // No host-side service dependencies; all services are injected lazily.
6
- export const inject = [];
10
+ // Lazy-injected services. All are resolved via ctx.inject() at apply-time, so
11
+ // the plugin still boots even when a service (e.g. workspaceRegistry) is
12
+ // unavailable; declaring them here documents the dependency.
13
+ export const inject = ['workspaceRegistry'];
7
14
  // ── Memory alert thresholds (RSS MB) and GC thresholds (major GC/min) ──
8
15
  // All values come from the single canonical DEFAULT_ALERT_CONFIG in
9
16
  // ./pure.js. The browser client half mirrors these (see lib/client.js
@@ -19,6 +26,14 @@ const DEFAULT_GC_THRESHOLD_INFO = DEFAULT_ALERT_CONFIG.gcThresholdInfo;
19
26
  const DEFAULT_GC_THRESHOLD_WARNING = DEFAULT_ALERT_CONFIG.gcThresholdWarning;
20
27
  const DEFAULT_GC_THRESHOLD_ERROR = DEFAULT_ALERT_CONFIG.gcThresholdError;
21
28
  const DEFAULT_MEM_GROWTH_ALERT_PER_MIN = DEFAULT_ALERT_CONFIG.memGrowthAlertPerMin;
29
+ // Session-footprint (disk) polling interval. Deliberately NOT a member of
30
+ // DEFAULT_ALERT_CONFIG: test/defaults-drift.test.ts asserts that object contains
31
+ // EXACTLY the 10 memory/GC keys, and a footprint key here would break that
32
+ // precise set. Disk scans are comparatively expensive (recursive readdir +
33
+ // stat over every session directory), so they run far less often than memory.
34
+ // The host caches the last scan and the client reads the cached snapshot, so
35
+ // the route itself never triggers an on-demand scan.
36
+ const FOOTPRINT_POLL_INTERVAL_MS = 300000; // 5 min
22
37
  /**
23
38
  * Sentinel passed to `query()` / `gcEvents()` as the `since` lower bound to
24
39
  * mean "no lower bound — return ALL stored records". These methods treat any
@@ -227,9 +242,12 @@ class MemoryTrendCollector {
227
242
  // because they are rare and the general ring buffer may evict them.
228
243
  if (isFullGc(kind)) {
229
244
  const last = this._gcMajorEvents[this._gcMajorEvents.length - 1];
230
- if (last && event.ts - last.ts < 500) {
245
+ if (last && event.ts - last.ts < 2000) {
231
246
  // Same full-GC cycle (phase entries arriving back-to-back):
232
247
  // merge into the existing event, keeping the worst-case pause.
248
+ // Window is generous (2s) because one cycle's phases can arrive
249
+ // over a second apart under real load; merging here prevents one
250
+ // Full GC from being reported as several adjacent events.
233
251
  last.duration = Math.max(last.duration, event.duration);
234
252
  last.ts = event.ts;
235
253
  }
@@ -284,6 +302,13 @@ class MemoryTrendCollector {
284
302
  // not the current owner, so overlapping apply/dispose cycles stay safe.
285
303
  let _memoryTrend = null;
286
304
  let _ownerCtx = null;
305
+ // ── Heap-snapshot dump state ─────────────────────────────────────────────
306
+ // The "dump heap now" disposal button on an alert POSTs dump-heap, which
307
+ // writes a .heapsnapshot (possibly tens–hundreds of MB) and records it here
308
+ // so the browser client can pull it down via the heap-dump route. Only the
309
+ // latest snapshot is kept for download; dumps are user-paced, not automatic,
310
+ // because writeHeapSnapshot() blocks the host main thread for seconds.
311
+ let _lastSnapshot = null;
287
312
  /** Adopt the process-wide collector for `ctx`; no-op if it is already owned. */
288
313
  function startTrend(ctx, config) {
289
314
  if (_ownerCtx && _ownerCtx !== ctx)
@@ -307,6 +332,173 @@ function stopTrend(ctx) {
307
332
  _ownerCtx = null;
308
333
  _memoryTrend?.stop();
309
334
  }
335
+ class SessionFootprintCollector {
336
+ _snapshot = null;
337
+ _timer = null;
338
+ _running = false;
339
+ start() {
340
+ if (this._running)
341
+ return;
342
+ this._running = true;
343
+ this._scan().then(() => { this._schedule(); }).catch(() => { this._schedule(); });
344
+ }
345
+ stop() {
346
+ this._running = false;
347
+ if (this._timer) {
348
+ clearTimeout(this._timer);
349
+ this._timer = null;
350
+ }
351
+ }
352
+ /** Immediately trigger a re-scan (e.g. right after the workspace registry is
353
+ * captured) so the very first archived count is not stuck at 0. */
354
+ rescan() {
355
+ if (!this._running)
356
+ return;
357
+ this._scan().catch(() => { });
358
+ }
359
+ snapshot() {
360
+ return this._snapshot;
361
+ }
362
+ _schedule() {
363
+ if (!this._running)
364
+ return;
365
+ this._timer = setTimeout(() => {
366
+ this._timer = null;
367
+ this._scan().then(() => { this._schedule(); }).catch(() => { this._schedule(); });
368
+ }, FOOTPRINT_POLL_INTERVAL_MS);
369
+ this._timer.unref?.();
370
+ }
371
+ async _scan() {
372
+ const sessionsRoot = resolveSessionsRoot();
373
+ const workspaces = await this._scanWorkspaces(sessionsRoot);
374
+ const snapshot = {
375
+ totalSessions: 0,
376
+ archivedSessions: 0,
377
+ bytes: 0,
378
+ workspaces,
379
+ scannedAt: Date.now(),
380
+ };
381
+ for (const w of workspaces) {
382
+ snapshot.totalSessions += w.totalSessions;
383
+ snapshot.archivedSessions += w.archivedSessions;
384
+ snapshot.bytes += w.bytes;
385
+ }
386
+ this._snapshot = snapshot;
387
+ }
388
+ async _scanWorkspaces(sessionsRoot) {
389
+ let workspaceDirs = [];
390
+ try {
391
+ workspaceDirs = await readdir(sessionsRoot, { withFileTypes: true }).then((ents) => ents.filter((e) => e.isDirectory()).map((e) => e.name));
392
+ }
393
+ catch (_e) {
394
+ return [];
395
+ }
396
+ const registry = getWorkspaceRegistry();
397
+ let archivedIds = [];
398
+ try {
399
+ if (registry && typeof registry.archivedSessionIds === 'function')
400
+ archivedIds = registry.archivedSessionIds();
401
+ else if (registry && Array.isArray(registry.archivedSessionIds))
402
+ archivedIds = registry.archivedSessionIds;
403
+ }
404
+ catch (_e) { /* keep empty */ }
405
+ const result = [];
406
+ for (const ws of workspaceDirs) {
407
+ const wsPath = join(sessionsRoot, ws);
408
+ let sessionNames = [];
409
+ try {
410
+ sessionNames = await readdir(wsPath, { withFileTypes: true }).then((ents) => ents.filter((e) => e.isDirectory() && /^session-/.test(e.name)).map((e) => e.name));
411
+ }
412
+ catch (_e) {
413
+ sessionNames = [];
414
+ }
415
+ const namesSet = new Set(sessionNames);
416
+ let bytes = 0;
417
+ for (const s of sessionNames) {
418
+ bytes += await dirSize(join(wsPath, s));
419
+ }
420
+ result.push({
421
+ workspace: ws,
422
+ totalSessions: sessionNames.length,
423
+ archivedSessions: countArchivedSessions(archivedIds, namesSet),
424
+ bytes,
425
+ });
426
+ }
427
+ return result;
428
+ }
429
+ }
430
+ /** Recursively sum the on-disk size of a session directory. */
431
+ async function dirSize(dir) {
432
+ let total = 0;
433
+ let entries = [];
434
+ try {
435
+ entries = await readdir(dir, { withFileTypes: true });
436
+ }
437
+ catch (_e) {
438
+ return 0;
439
+ }
440
+ for (const e of entries) {
441
+ const p = join(dir, e.name);
442
+ try {
443
+ if (e.isDirectory()) {
444
+ total += await dirSize(p);
445
+ }
446
+ else if (e.isSymbolicLink()) {
447
+ continue;
448
+ }
449
+ else {
450
+ const s = await stat(p);
451
+ total += s.size;
452
+ }
453
+ }
454
+ catch (_e) { /* skip */ }
455
+ }
456
+ return total;
457
+ }
458
+ /**
459
+ * Resolve the DSH sessions root the same way the harness seeds `$DSH_HOME`:
460
+ * configured override > `$DSH_HOME` env > `~/.dsh` (opened home). `$DSH_HOME`
461
+ * may itself contain a `~` or trailing slashes, so expand the home and join the
462
+ * well-known `sessions` sub-directory explicitly. Returns `''` if a home could
463
+ * not be resolved at all (the scan then simply reports zero workspaces).
464
+ */
465
+ function resolveSessionsRoot() {
466
+ const env = process.env.DSH_HOME;
467
+ const home = env && env.trim()
468
+ ? env.replace(/^~(?=\/|\\)/, homedir())
469
+ : join(homedir(), '.dsh');
470
+ return join(home, 'sessions');
471
+ }
472
+ let _footprint = null;
473
+ let _footprintOwner = null;
474
+ // Cached reference to the DSH workspace-registry service, resolved lazily via
475
+ // ctx.inject. When it is unavailable (or the plugin was applied without it),
476
+ // archived-session counting degrades to whatever the registry can provide —
477
+ // if the registry is truly absent the archived count is reported as 0 rather
478
+ // than guessing from the disk (disk directories carry no archive flag).
479
+ let _workspaceRegistry = null;
480
+ function setWorkspaceRegistry(wsCtx) {
481
+ const reg = wsCtx?.workspaceRegistry;
482
+ if (reg != null)
483
+ _workspaceRegistry = reg;
484
+ }
485
+ function getWorkspaceRegistry() {
486
+ return _workspaceRegistry ?? null;
487
+ }
488
+ function startFootprint(ctx) {
489
+ if (_footprintOwner && _footprintOwner !== ctx)
490
+ return;
491
+ if (!_footprint)
492
+ _footprint = new SessionFootprintCollector();
493
+ _footprintOwner = ctx;
494
+ _footprint.start();
495
+ }
496
+ function stopFootprint(ctx) {
497
+ if (_footprintOwner !== ctx)
498
+ return;
499
+ _footprintOwner = null;
500
+ _footprint?.stop();
501
+ }
310
502
  // ── Private HTTP helpers ────────────────────────────────────────────────
311
503
  /** Send a JSON response with no-store cache control. */
312
504
  function sendJson(res, status, payload) {
@@ -321,6 +513,26 @@ export function apply(ctx, config) {
321
513
  ctx.inject(['settings'], (settingsCtx) => {
322
514
  settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber));
323
515
  });
516
+ // ── Workspace-registry capture ───────────────────────────────────────
517
+ // Grab the runtime's archived-session id list so the footprint collector can
518
+ // intersect it with on-disk session dirs. The registry is injected lazily;
519
+ // if it is unavailable at apply-time the footprint still scans the disk (and
520
+ // simply reports the archived count from whatever the registry later yields,
521
+ // or 0 if it never appears).
522
+ ctx.inject(['workspaceRegistry'], (regCtx) => {
523
+ setWorkspaceRegistry(regCtx);
524
+ // The registry may arrive after the collector's first scan already ran
525
+ // with no archived ids → refresh it immediately so the count is correct.
526
+ _footprint?.rescan();
527
+ });
528
+ // ── Session-footprint collector bootstrap ────────────────────────────
529
+ // Low-frequency disk scan (5-minute cadence by default), cached on the host.
530
+ // Owned exclusively by this ctx; overlapping apply/dispose cycles are safe.
531
+ startFootprint(ctx);
532
+ ctx.effect(() => {
533
+ const owner = ctx;
534
+ return () => { stopFootprint(owner); };
535
+ }, 'dsh-flash-mem-mon: session-footprint collector lifecycle');
324
536
  // ── Memory trend collector bootstrap ──────────────────────────────────
325
537
  // One bounded ring buffer per process, owned exclusively by the active ctx.
326
538
  // startTrend/stopTrend are idempotent and owner-guarded, so overlapping
@@ -387,5 +599,98 @@ export function apply(ctx, config) {
387
599
  }
388
600
  },
389
601
  }), 'dsh-flash-mem-mon: GET /plugins/dsh-flash-mem-mon/memory-trend');
602
+ // GET /plugins/dsh-flash-mem-mon/session-footprint
603
+ // Returns the host's cached session-footprint snapshot (total / archived
604
+ // session counts + per-workspace and total on-disk bytes). Serves only the
605
+ // cached result of the low-frequency disk scan — never triggers a scan on
606
+ // demand, so repeated client polls (or a reload while the modal is open)
607
+ // cannot hammer the filesystem. `scannedAt` is the unix-ms time of the last
608
+ // completed scan (0 before the first scan finishes).
609
+ wsCtx.effect(() => ws.register({
610
+ kind: 'exact',
611
+ path: '/plugins/dsh-flash-mem-mon/session-footprint',
612
+ handler: async (req, res) => {
613
+ if (req.method !== 'GET') {
614
+ res.statusCode = 405;
615
+ res.setHeader('allow', 'GET');
616
+ res.end();
617
+ return;
618
+ }
619
+ const snap = _footprint?.snapshot() ?? null;
620
+ sendJson(res, 200, snap ?? {
621
+ totalSessions: 0,
622
+ archivedSessions: 0,
623
+ bytes: 0,
624
+ workspaces: [],
625
+ scannedAt: 0,
626
+ });
627
+ },
628
+ }), 'dsh-flash-mem-mon: GET /plugins/dsh-flash-mem-mon/session-footprint');
629
+ // POST /plugins/dsh-flash-mem-mon/dump-heap
630
+ // Triggered by the "立即转储堆快照" disposal button attached to a memory
631
+ // alert (see lib/client.js createHostMemoryAlertProvider → actions).
632
+ // Synchronously writes a .heapsnapshot into the OS temp dir (the one
633
+ // acceptable place to block the main thread: the user asked for it), then
634
+ // records it as the latest snapshot and returns its name+size so the
635
+ // client can confirm and pull it down. Auto-dump is deliberately NOT
636
+ // offered anywhere — a runaway gleaning loop must never stall the host.
637
+ wsCtx.effect(() => ws.register({
638
+ kind: 'exact',
639
+ path: '/plugins/dsh-flash-mem-mon/dump-heap',
640
+ handler: async (_req, res) => {
641
+ if (_req.method !== 'POST') {
642
+ res.statusCode = 405;
643
+ res.setHeader('allow', 'POST');
644
+ res.end();
645
+ return;
646
+ }
647
+ const name = `dsh-flash-mem-mon-${Date.now()}.${process.pid}.heapsnapshot`;
648
+ const path = join(tmpdir(), name);
649
+ try {
650
+ writeHeapSnapshot(path);
651
+ const info = await stat(path);
652
+ _lastSnapshot = { name, path, size: info.size };
653
+ sendJson(res, 200, { ok: true, name, size: info.size, path });
654
+ }
655
+ catch (err) {
656
+ sendJson(res, 500, { ok: false, error: (err && err.message) || 'heap snapshot failed' });
657
+ }
658
+ },
659
+ }), 'dsh-flash-mem-mon: POST /plugins/dsh-flash-mem-mon/dump-heap');
660
+ // GET /plugins/dsh-flash-mem-mon/heap-dump
661
+ // Streams the most recent heap snapshot back to the client as an
662
+ // attachment. One latest-at-a-time is fine here: dumps are user-paced and
663
+ // downloading is immediate, so keeping a whole archive is unnecessary
664
+ // (and the tmp files are already pruned by the OS over time).
665
+ wsCtx.effect(() => ws.register({
666
+ kind: 'exact',
667
+ path: '/plugins/dsh-flash-mem-mon/heap-dump',
668
+ handler: async (_req, res) => {
669
+ if (_req.method !== 'GET') {
670
+ res.statusCode = 405;
671
+ res.setHeader('allow', 'GET');
672
+ res.end();
673
+ return;
674
+ }
675
+ const snap = _lastSnapshot;
676
+ if (!snap) {
677
+ sendJson(res, 404, { error: 'No heap snapshot available yet' });
678
+ return;
679
+ }
680
+ try {
681
+ await stat(snap.path); // ensure it still exists
682
+ res.statusCode = 200;
683
+ res.setHeader('content-type', 'application/octet-stream');
684
+ res.setHeader('content-length', String(snap.size));
685
+ res.setHeader('content-disposition', `attachment; filename="${snap.name}"`);
686
+ res.setHeader('cache-control', 'no-store');
687
+ createReadStream(snap.path).pipe(res);
688
+ }
689
+ catch (_e) {
690
+ _lastSnapshot = null; // stale entry; drop it
691
+ sendJson(res, 404, { error: 'Heap snapshot no longer available' });
692
+ }
693
+ },
694
+ }), 'dsh-flash-mem-mon: GET /plugins/dsh-flash-mem-mon/heap-dump');
390
695
  });
391
696
  }
package/dist/pure.js CHANGED
@@ -6,7 +6,7 @@ export const DEFAULT_ALERT_CONFIG = {
6
6
  memThresholdWarning: 512,
7
7
  memThresholdError: 1024,
8
8
  memThresholdCritical: 1536,
9
- memPollBase: 30000,
9
+ memPollBase: 60000,
10
10
  memPollMin: 2000,
11
11
  gcThresholdInfo: 2,
12
12
  gcThresholdWarning: 5,
@@ -64,3 +64,56 @@ export function rssSlopePerMin(samples, tailCount = 20) {
64
64
  const samplesPerMinute = 60000 / avgIntervalMs;
65
65
  return Math.round((slope * samplesPerMinute / 1048576) * 100) / 100;
66
66
  }
67
+ // ── Session-footprint helpers ─────────────────────────────────────────────
68
+ // These are derived purely from collected inputs so the aggregate math and the
69
+ // human-readable formatting can be unit tested without touching the filesystem.
70
+ /**
71
+ * Derive the number of ARCHIVED sessions that are still physically present on
72
+ * disk. The DSH runtime's `workspaceRegistry.archivedSessionIds` lists archived
73
+ * session ids, but some may have already been pruned from disk (so the disk
74
+ * `sessions/<workspace>/session-<uuid>` directories can be fewer than the ids).
75
+ * The truth source for "archived" is the runtime id list; the disk constrains
76
+ * it to what still exists. Pass `sessionNames` as the on-disk directory names
77
+ * (e.g. `new Set(['session-a', 'session-b', ...])`).
78
+ */
79
+ export function countArchivedSessions(archivedIds, sessionNames) {
80
+ let count = 0;
81
+ for (const id of archivedIds) {
82
+ if (sessionNames.has(id) || sessionNames.has(`session-${id}`))
83
+ count++;
84
+ }
85
+ return count;
86
+ }
87
+ /** Format a byte count for display, e.g. 1536 → '1.5 KB', 7340032 → '7.0 MB'. */
88
+ export function formatBytes(bytes) {
89
+ if (!Number.isFinite(bytes) || bytes < 0)
90
+ return '—';
91
+ if (bytes < 1024)
92
+ return `${bytes} B`;
93
+ const units = ['KB', 'MB', 'GB', 'TB'];
94
+ let value = bytes / 1024;
95
+ let unit = 0;
96
+ while (value >= 1024 && unit < units.length - 1) {
97
+ value /= 1024;
98
+ unit++;
99
+ }
100
+ return `${value.toFixed(1)} ${units[unit]}`;
101
+ }
102
+ /** Format a duration (ms) as a human freshness string, e.g. '5 分钟前'. */
103
+ export function formatDurationAgo(nowMs, thenMs) {
104
+ if (!Number.isFinite(nowMs) || !Number.isFinite(thenMs) || thenMs <= 0 || nowMs < thenMs)
105
+ return null;
106
+ const sec = Math.floor((nowMs - thenMs) / 1000);
107
+ if (sec < 60)
108
+ return sec <= 1 ? '刚刚' : `${sec} 秒前`;
109
+ if (sec < 3600)
110
+ return `${Math.floor(sec / 60)} 分钟前`;
111
+ return `${Math.floor(sec / 3600)} 小时前`;
112
+ }
113
+ /** Sum an array of byte counts (per-workspace disk usage) into a single total. */
114
+ export function sumBytes(parts) {
115
+ let total = 0;
116
+ for (const p of parts)
117
+ total += p;
118
+ return total;
119
+ }
package/lib/client.js CHANGED
@@ -60,7 +60,7 @@ window.__ModuleLoader__.load({
60
60
  //#region i18n ────────────────────────────────────────────────────────────
61
61
 
62
62
  var zh = {
63
- alertMemCluster: '内存监控',
63
+ alertMemCluster: '性能监控',
64
64
  monitorOn: '已开启',
65
65
  monitorOff: '已关闭',
66
66
  monitorConfig: '配置',
@@ -108,6 +108,11 @@ window.__ModuleLoader__.load({
108
108
  alertHostGcWarning: 'GC 频率异常',
109
109
  alertHostGcError: 'GC 频率极高',
110
110
  alertHostGcMsg: 'Major {v} 次/min | 暂停 {pause} ms/min | Minor {minor} 次 | RSS {rss} MB | 堆 {heap}/{heapTotal} MB ({pct}%)',
111
+ // Alert disposal buttons (registered via `actions` — see dsh-flash contract)
112
+ alertDisposeDumpHeap: '立即转储堆快照',
113
+ alertDumping: '正在转储堆快照…',
114
+ alertDumpSaved: '已保存快照 {name}({size}),可在 DevTools → Memory → Load 中载入分析',
115
+ alertDumpFailed: '堆快照失败:{error}',
111
116
  alertSevInfo: '信息',
112
117
  alertSevWarning: '警告',
113
118
  alertSevError: '错误',
@@ -153,10 +158,16 @@ window.__ModuleLoader__.load({
153
158
  memTrendAxisHeap: '堆',
154
159
  memTrendAxisTime: '时间',
155
160
  memTrendInfo: '趋势信息',
161
+ // Session footprint
162
+ footprintTitle: '会话足迹',
163
+ footprintTotalSessions: '总会话数',
164
+ footprintArchivedSessions: '归档会话数',
165
+ footprintOnDisk: '磁盘占用',
166
+ footprintNoData: '暂无足迹数据',
156
167
  }
157
168
 
158
169
  var en = {
159
- alertMemCluster: 'Memory Monitor',
170
+ alertMemCluster: 'Performance Monitor',
160
171
  monitorOn: 'Enabled',
161
172
  monitorOff: 'Disabled',
162
173
  monitorConfig: 'Configure',
@@ -204,6 +215,11 @@ window.__ModuleLoader__.load({
204
215
  alertHostGcWarning: 'GC frequency abnormal',
205
216
  alertHostGcError: 'GC frequency critical',
206
217
  alertHostGcMsg: 'Major {v}/min | Pause {pause} ms/min | Minor {minor} | RSS {rss} MB | Heap {heap}/{heapTotal} MB ({pct}%)',
218
+ // Alert disposal buttons (registered via `actions` — see dsh-flash contract)
219
+ alertDisposeDumpHeap: 'Dump heap now',
220
+ alertDumping: 'Dumping heap snapshot…',
221
+ alertDumpSaved: 'Saved snapshot {name} ({size}). Load it in DevTools → Memory to analyze.',
222
+ alertDumpFailed: 'Heap dump failed: {error}',
207
223
  alertSevInfo: 'Info',
208
224
  alertSevWarning: 'Warning',
209
225
  alertSevError: 'Error',
@@ -249,6 +265,12 @@ window.__ModuleLoader__.load({
249
265
  memTrendAxisHeap: 'Heap',
250
266
  memTrendAxisTime: 'Time',
251
267
  memTrendInfo: 'Trend Info',
268
+ // Session footprint
269
+ footprintTitle: 'Session Footprint',
270
+ footprintTotalSessions: 'Total Sessions',
271
+ footprintArchivedSessions: 'Archived Sessions',
272
+ footprintOnDisk: 'On Disk',
273
+ footprintNoData: 'No footprint data yet',
252
274
  }
253
275
 
254
276
  // ── i18n: a proxy over DSH's official locale framework ─────────────────
@@ -438,7 +460,7 @@ window.__ModuleLoader__.load({
438
460
  memThresholdWarning: 512,
439
461
  memThresholdError: 1024,
440
462
  memThresholdCritical: 1536,
441
- memPollBase: 30000,
463
+ memPollBase: 60000,
442
464
  memPollMin: 2000,
443
465
  gcThresholdInfo: 2,
444
466
  gcThresholdWarning: 5,
@@ -696,6 +718,52 @@ var _PREFS_MAX_RETRIES = 8
696
718
  disarmAbsent()
697
719
  }
698
720
 
721
+ /** Disposal actions attached to every memory/GC alert. dsh-flash renders
722
+ * one button per entry and calls onClick(alert, api). Contract in
723
+ * dsh-flash/lib/client.js `_showAlertDetail` → plugin-registered action
724
+ * buttons. Returning a Promise keeps the button disabled until settled;
725
+ * returning {message} keeps the detail modal open so the saved location
726
+ * stays visible (the user pulls down the .heapsnapshot browser download
727
+ * we trigger programmatically — the status line is plain text, not HTML,
728
+ * so we cannot render a live link into it). */
729
+ function _dumpHeapActions() {
730
+ return [{
731
+ id: 'dump-heap',
732
+ label: function () { return t('alertDisposeDumpHeap') },
733
+ primary: true,
734
+ onClick: function (_alert, api) {
735
+ api.status(t('alertDumping'), 'info')
736
+ return fetch('/plugins/dsh-flash-mem-mon/dump-heap', { method: 'POST' })
737
+ .then(function (r) { return r.json().catch(function () { return null }) })
738
+ .then(function (d) {
739
+ if (!d || !d.ok || !d.name) {
740
+ var err = (d && d.error) || 'unknown'
741
+ api.status(t('alertDumpFailed').replace('{error}', err), 'error')
742
+ return { message: t('alertDumpFailed').replace('{error}', err), level: 'error' }
743
+ }
744
+ // Stream the snapshot down as a browser download (same origin).
745
+ var a = document.createElement('a')
746
+ a.href = '/plugins/dsh-flash-mem-mon/heap-dump'
747
+ a.download = d.name
748
+ document.body.appendChild(a)
749
+ a.click()
750
+ a.remove()
751
+ var size = d.size >= 1048576
752
+ ? (d.size / 1048576).toFixed(1) + ' MB'
753
+ : Math.max(1, Math.round(d.size / 1024)) + ' KB'
754
+ var msg = t('alertDumpSaved').replace('{name}', d.name).replace('{size}', size)
755
+ api.status(msg, 'info')
756
+ return { message: msg, level: 'info' }
757
+ })
758
+ .catch(function (err) {
759
+ var msg = t('alertDumpFailed').replace('{error}', (err && err.message) || 'network')
760
+ api.status(msg, 'error')
761
+ return { message: msg, level: 'error' }
762
+ })
763
+ },
764
+ }]
765
+ }
766
+
699
767
  function poll() {
700
768
  if (!callback) return
701
769
  var alerts = []
@@ -757,6 +825,7 @@ var _PREFS_MAX_RETRIES = 8
757
825
  icon: '🔴',
758
826
  timestamp: Date.now(),
759
827
  dismissible: true,
828
+ actions: _dumpHeapActions(),
760
829
  })
761
830
  } else if (rssMB >= tErr) {
762
831
  emitIfChanged(alerts, {
@@ -767,6 +836,7 @@ var _PREFS_MAX_RETRIES = 8
767
836
  icon: '🟠',
768
837
  timestamp: Date.now(),
769
838
  dismissible: true,
839
+ actions: _dumpHeapActions(),
770
840
  })
771
841
  } else if (rssMB >= tWarn) {
772
842
  emitIfChanged(alerts, {
@@ -777,6 +847,7 @@ var _PREFS_MAX_RETRIES = 8
777
847
  icon: '🟡',
778
848
  timestamp: Date.now(),
779
849
  dismissible: true,
850
+ actions: _dumpHeapActions(),
780
851
  })
781
852
  } else if (rssMB >= tInfo) {
782
853
  emitIfChanged(alerts, {
@@ -787,6 +858,7 @@ var _PREFS_MAX_RETRIES = 8
787
858
  icon: '🔵',
788
859
  timestamp: Date.now(),
789
860
  dismissible: true,
861
+ actions: _dumpHeapActions(),
790
862
  })
791
863
  }
792
864
 
@@ -816,6 +888,7 @@ var _PREFS_MAX_RETRIES = 8
816
888
  icon: '📈',
817
889
  timestamp: Date.now(),
818
890
  dismissible: true,
891
+ actions: _dumpHeapActions(),
819
892
  })
820
893
  }
821
894
  }
@@ -853,6 +926,7 @@ var _PREFS_MAX_RETRIES = 8
853
926
  icon: '🟠',
854
927
  timestamp: Date.now(),
855
928
  dismissible: true,
929
+ actions: _dumpHeapActions(),
856
930
  })
857
931
  } else if (mpm >= gcWarn) {
858
932
  emitIfChanged(alerts, {
@@ -863,6 +937,7 @@ var _PREFS_MAX_RETRIES = 8
863
937
  icon: '🟡',
864
938
  timestamp: Date.now(),
865
939
  dismissible: true,
940
+ actions: _dumpHeapActions(),
866
941
  })
867
942
  } else if (mpm >= gcInfo) {
868
943
  emitIfChanged(alerts, {
@@ -873,6 +948,7 @@ var _PREFS_MAX_RETRIES = 8
873
948
  icon: '🔵',
874
949
  timestamp: Date.now(),
875
950
  dismissible: true,
951
+ actions: _dumpHeapActions(),
876
952
  })
877
953
  }
878
954
  }
@@ -1388,6 +1464,11 @@ var _PREFS_MAX_RETRIES = 8
1388
1464
  var _gcEvents = useState(null)
1389
1465
  var gcEvents = _gcEvents[0], setGcEvents = _gcEvents[1]
1390
1466
 
1467
+ // Session footprint — total/archived session counts and on-disk usage
1468
+ // from our own /session-footprint route (cached host snapshot).
1469
+ var _footprint = useState(null)
1470
+ var footprint = _footprint[0], setFootprint = _footprint[1]
1471
+
1391
1472
  // Sparkline hover index (null = not hovering).
1392
1473
  var _hoverIdx = useState(null)
1393
1474
  var hoverIdx = _hoverIdx[0], setHoverIdx = _hoverIdx[1]
@@ -1459,6 +1540,22 @@ var _PREFS_MAX_RETRIES = 8
1459
1540
  return function () { clearInterval(sampleTimer) }
1460
1541
  }, [])
1461
1542
 
1543
+ // Session footprint — the host collector rescans no more often than every
1544
+ // ~5 min and serves a cached snapshot with the routes' last `scannedAt`, so
1545
+ // the panel is fetched only occasionally; a low-frequency fallback keeps
1546
+ // the panel live even if the shared cache never lands here.
1547
+ useEffect(function () {
1548
+ function fetchFootprint() {
1549
+ fetch('/plugins/dsh-flash-mem-mon/session-footprint')
1550
+ .then(function (r) { return r.json() })
1551
+ .then(function (d) { if (d) setFootprint(d) })
1552
+ .catch(function () {})
1553
+ }
1554
+ fetchFootprint()
1555
+ var footprintTimer = setInterval(fetchFootprint, 60000)
1556
+ return function () { clearInterval(footprintTimer) }
1557
+ }, [])
1558
+
1462
1559
  // Render a single slider field row.
1463
1560
  function renderSliderField(f) {
1464
1561
  var val = Number(_alertPref(f.key)) || 0
@@ -2039,30 +2136,44 @@ var _PREFS_MAX_RETRIES = 8
2039
2136
  if (gcEvents && gcEvents.length > 0) {
2040
2137
  var tsFirst = trendSamples[0].ts
2041
2138
  var tsLast = trendSamples[trendSamples.length - 1].ts
2139
+ // Track which sample RSS-drop segments have already been attributed to a
2140
+ // Full GC event. Without this, two Full GCs that fire around the same
2141
+ // memory release can both grab the same -XX MB drop and double count it.
2142
+ var sampleDropClaimed = {}
2042
2143
  for (var ei = 0; ei < gcEvents.length; ei++) {
2043
2144
  var ge = gcEvents[ei]
2044
2145
  if (ge.ts < tsFirst || ge.ts > tsLast) continue
2045
2146
  // Try to find the RSS drop around this GC event from samples.
2046
2147
  var rssDropMB = null
2148
+ var merged = false
2047
2149
  // First: check if it merges with an existing sample-sourced RSS drop event (within 5s).
2048
- var duped = false
2049
2150
  for (var di = 0; di < dropEvents.length; di++) {
2050
- if (dropEvents[di].source === 'sample' && Math.abs(dropEvents[di].ts - ge.ts) < 5000) {
2051
- rssDropMB = dropEvents[di].rssDropMB
2052
- dropEvents[di].source = 'gc'
2053
- dropEvents[di].duration = ge.duration
2054
- dropEvents[di].gcIdx = ei
2055
- duped = true
2151
+ var dropItem = dropEvents[di]
2152
+ if (dropItem.source === 'sample' && Math.abs(dropItem.ts - ge.ts) < 5000) {
2153
+ if (!sampleDropClaimed[dropItem.sampleIdx]) {
2154
+ sampleDropClaimed[dropItem.sampleIdx] = true
2155
+ rssDropMB = dropItem.rssDropMB
2156
+ dropItem.source = 'gc'
2157
+ dropItem.duration = ge.duration
2158
+ dropItem.gcIdx = ei
2159
+ merged = true
2160
+ }
2161
+ // If this drop was already claimed by an earlier GC, don't
2162
+ // attribute it again — fall through and add this GC as its own
2163
+ // row without reusing the same memory-release number.
2056
2164
  break
2057
2165
  }
2058
2166
  }
2059
- // Second: if no existing RSS drop matched, scan nearby samples for a drop.
2060
- if (!duped) {
2167
+ // Second: if no existing RSS drop merged, scan nearby samples for a
2168
+ // drop, skipping segments already claimed by an earlier event.
2169
+ if (!merged) {
2061
2170
  for (var si2 = 1; si2 < trendSamples.length; si2++) {
2062
2171
  var timeDiff = Math.abs(trendSamples[si2].ts - ge.ts)
2063
2172
  if (timeDiff > 10000) continue // only look within ±10s
2173
+ if (sampleDropClaimed[si2]) continue // drop already attributed elsewhere
2064
2174
  var nearDrop = trendSamples[si2 - 1].rss - trendSamples[si2].rss
2065
2175
  if (nearDrop > 0) {
2176
+ sampleDropClaimed[si2] = true
2066
2177
  rssDropMB = (nearDrop / 1048576).toFixed(1)
2067
2178
  break
2068
2179
  }
@@ -2078,6 +2189,10 @@ var _PREFS_MAX_RETRIES = 8
2078
2189
  }
2079
2190
  }
2080
2191
  }
2192
+ // Merge both sources back into strict chronological order. A Full GC event
2193
+ // found outside any sample's RSS-drop window is appended after the sample
2194
+ // events, which may leave the combined list out of order.
2195
+ dropEvents.sort(function (a, b) { return a.ts - b.ts })
2081
2196
  if (dropEvents.length === 0 && (!trendData.gc || trendData.gc.majorCount === 0)) {
2082
2197
  return h('div', null,
2083
2198
  h('span', { style: S.monitorConfigGroupTitle }, t('memGcEventList')),
@@ -2170,11 +2285,58 @@ var _PREFS_MAX_RETRIES = 8
2170
2285
  )
2171
2286
  }
2172
2287
 
2288
+ var footprintSectionEl = (function () {
2289
+ function formatBytesSync(n) {
2290
+ if (!isFinite(n) || n < 0) return '—'
2291
+ if (n < 1024) return n + ' B'
2292
+ var units = ['KB', 'MB', 'GB', 'TB', 'PB']
2293
+ var v = n
2294
+ var u = -1
2295
+ do { v /= 1024; u++ } while (v >= 1024 && u < units.length - 1)
2296
+ return v.toFixed(v >= 100 ? 0 : 1) + ' ' + units[u]
2297
+ }
2298
+ var fp = footprint
2299
+ var totalBytes = 0
2300
+ var totalSessions = 0
2301
+ var totalArchived = 0
2302
+ var archivedKnown = false
2303
+ if (fp && Array.isArray(fp.workspaces)) {
2304
+ fp.workspaces.forEach(function (w) {
2305
+ totalSessions += Number(w.totalSessions) || 0
2306
+ totalArchived += Number(w.archivedSessions) || 0
2307
+ totalBytes += Number(w.bytes) || 0
2308
+ if (typeof w.archivedSessions === 'number') archivedKnown = true
2309
+ })
2310
+ }
2311
+ if (!fp) {
2312
+ return h('div', { style: Object.assign({}, S.memColumnCard, { marginTop: '8px' }) },
2313
+ h('span', { style: S.memSubSectionLabel }, t('footprintTitle')),
2314
+ h('span', { style: S.memLiveDataLabel }, t('footprintNoData')),
2315
+ )
2316
+ }
2317
+ return h('div', { style: Object.assign({}, S.memColumnCard, { marginTop: '8px' }) },
2318
+ h('span', { style: S.memSubSectionLabel }, t('footprintTitle')),
2319
+ h('div', { style: S.memLiveDataGrid },
2320
+ h('span', { style: S.memLiveDataLabel }, t('footprintTotalSessions')),
2321
+ h('span', { style: S.memLiveDataValue }, String(totalSessions)),
2322
+ h('span', { style: S.memLiveDataLabel }, t('footprintArchivedSessions')),
2323
+ h('span', { style: S.memLiveDataValue },
2324
+ archivedKnown
2325
+ ? String(totalArchived)
2326
+ : '—',
2327
+ ),
2328
+ h('span', { style: S.memLiveDataLabel }, t('footprintOnDisk')),
2329
+ h('span', { style: S.memLiveDataValue }, formatBytesSync(totalBytes)),
2330
+ ),
2331
+ )
2332
+ })()
2333
+
2173
2334
  memLiveDataEl = h('div', { key: 'live-data' },
2174
2335
  h('div', { style: S.monitorModalDivider }),
2175
2336
  h('span', { style: S.monitorModalSectionTitle }, t('memLiveData')),
2176
2337
  hostSectionEl,
2177
2338
  trendSectionEl,
2339
+ footprintSectionEl,
2178
2340
  )
2179
2341
 
2180
2342
  return h('div', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-flash-mem-mon",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
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
5
  "license": "Apache-2.0",
6
6
  "type": "module",