claw-dashboard 1.9.0 → 2.1.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.
Files changed (135) hide show
  1. package/.c8rc.json +38 -0
  2. package/.dockerignore +68 -0
  3. package/.github/dependabot.yml +45 -0
  4. package/.github/pull_request_template.md +39 -0
  5. package/.github/workflows/ci.yml +147 -0
  6. package/.github/workflows/release.yml +40 -0
  7. package/.github/workflows/security.yml +84 -0
  8. package/.husky/pre-commit +1 -0
  9. package/.planning/codebase/ARCHITECTURE.md +206 -0
  10. package/.planning/codebase/INTEGRATIONS.md +150 -0
  11. package/.planning/codebase/STACK.md +122 -0
  12. package/.planning/codebase/STRUCTURE.md +201 -0
  13. package/CHANGELOG.md +293 -0
  14. package/CONTRIBUTING.md +378 -0
  15. package/Dockerfile +54 -0
  16. package/FEATURES.md +195 -21
  17. package/README.md +83 -6
  18. package/TODO.md +28 -0
  19. package/build-cjs.js +127 -0
  20. package/cjs-shim.js +13 -0
  21. package/dist/clawdash +1729 -0
  22. package/dist/clawdash.meta.json +2236 -0
  23. package/dist/widgets.cjs +6511 -0
  24. package/docker-compose.yml +67 -0
  25. package/docs/API.md +1050 -0
  26. package/docs/PLUGINS.md +1504 -0
  27. package/esbuild.config.js +158 -0
  28. package/eslint.config.js +56 -0
  29. package/examples/plugins/README.md +122 -0
  30. package/examples/plugins/api-status/index.js +294 -0
  31. package/examples/plugins/api-status/plugin.json +19 -0
  32. package/examples/plugins/hello-world/index.js +104 -0
  33. package/examples/plugins/hello-world/plugin.json +15 -0
  34. package/examples/plugins/system-metrics-chart/index.js +339 -0
  35. package/examples/plugins/system-metrics-chart/plugin.json +18 -0
  36. package/examples/plugins/weather-widget/index.js +136 -0
  37. package/examples/plugins/weather-widget/plugin.json +19 -0
  38. package/index.cjs +23005 -0
  39. package/index.js +5285 -566
  40. package/jest.config.js +11 -0
  41. package/man/clawdash.1 +276 -0
  42. package/package.json +54 -6
  43. package/schemas/plugin-manifest.json +128 -0
  44. package/scripts/release.js +595 -0
  45. package/src/alerts.js +693 -0
  46. package/src/auto-save.js +584 -0
  47. package/src/cache.js +390 -0
  48. package/src/checksum.js +146 -0
  49. package/src/cli/args.js +110 -0
  50. package/src/cli/export-schedule.js +423 -0
  51. package/src/cli/export-snapshot.js +138 -0
  52. package/src/cli/help.js +69 -0
  53. package/src/cli/import-snapshot.js +230 -0
  54. package/src/cli/index.js +14 -0
  55. package/src/cli/list-templates.js +69 -0
  56. package/src/cli/validate-config.js +76 -0
  57. package/src/cli/validate-plugin.js +187 -0
  58. package/src/cli/version.js +15 -0
  59. package/src/config-validator.js +586 -0
  60. package/src/config-watcher.js +401 -0
  61. package/src/config.js +684 -0
  62. package/src/container-detector.js +499 -0
  63. package/src/database.js +734 -0
  64. package/src/differential-render.js +327 -0
  65. package/src/errors.js +169 -0
  66. package/src/export-scheduler.js +581 -0
  67. package/src/gateway-manager.js +685 -0
  68. package/src/hints.js +371 -0
  69. package/src/loading-states.js +509 -0
  70. package/src/logger.js +185 -0
  71. package/src/memory-pressure.js +467 -0
  72. package/src/performance-monitor.js +374 -0
  73. package/src/plugin-errors.js +586 -0
  74. package/src/plugin-manifest-validator.js +219 -0
  75. package/src/plugin-reload.js +481 -0
  76. package/src/plugin-scaffold.js +1934 -0
  77. package/src/plugin-validator.js +284 -0
  78. package/src/retry.js +218 -0
  79. package/src/security.js +860 -0
  80. package/src/snapshot.js +372 -0
  81. package/src/splash.js +115 -0
  82. package/src/theme-selector.js +411 -0
  83. package/src/themes.js +874 -0
  84. package/src/transitions.js +534 -0
  85. package/src/types.d.ts +174 -0
  86. package/src/validation.js +926 -0
  87. package/src/web-server.js +885 -0
  88. package/src/widgets/builtin-widgets.js +1056 -0
  89. package/src/widgets/config-processor.js +401 -0
  90. package/src/widgets/dependency-resolver.js +596 -0
  91. package/src/widgets/index.js +79 -0
  92. package/src/widgets/plugin-api.js +845 -0
  93. package/src/widgets/widget-error-boundary.js +773 -0
  94. package/src/widgets/widget-error-isolation.js +551 -0
  95. package/src/widgets/widget-loader.js +1437 -0
  96. package/src/workers/system-worker.js +130 -0
  97. package/src/workers/worker-pool.js +633 -0
  98. package/tests/alerts.test.js +437 -0
  99. package/tests/auto-save.test.js +529 -0
  100. package/tests/cache.test.js +317 -0
  101. package/tests/cli.test.js +351 -0
  102. package/tests/command-palette.test.js +320 -0
  103. package/tests/config-processor.test.js +452 -0
  104. package/tests/config-validator.test.js +710 -0
  105. package/tests/config-watcher.test.js +594 -0
  106. package/tests/config.test.js +755 -0
  107. package/tests/database.test.js +438 -0
  108. package/tests/errors.test.js +189 -0
  109. package/tests/example-plugins.test.js +624 -0
  110. package/tests/integration.test.js +1321 -0
  111. package/tests/loading-states.test.js +300 -0
  112. package/tests/manifest-validation-on-load.test.js +671 -0
  113. package/tests/performance-monitor.test.js +190 -0
  114. package/tests/plugin-api-rate-limit.test.js +302 -0
  115. package/tests/plugin-errors.test.js +311 -0
  116. package/tests/plugin-lifecycle-e2e.test.js +1036 -0
  117. package/tests/plugin-reload.test.js +764 -0
  118. package/tests/rate-limiter.test.js +539 -0
  119. package/tests/retry.test.js +308 -0
  120. package/tests/security.test.js +411 -0
  121. package/tests/settings-modal.test.js +226 -0
  122. package/tests/theme-selector.test.js +57 -0
  123. package/tests/utils.js +242 -0
  124. package/tests/utils.test.js +317 -0
  125. package/tests/validate-plugin-cli.test.js +359 -0
  126. package/tests/validation.test.js +837 -0
  127. package/tests/web-server.test.js +646 -0
  128. package/tests/widget-arrange-mode.test.js +183 -0
  129. package/tests/widget-config-hot-reload.test.js +465 -0
  130. package/tests/widget-dependency.test.js +591 -0
  131. package/tests/widget-error-boundary.test.js +749 -0
  132. package/tests/widget-error-isolation.test.js +469 -0
  133. package/tests/widget-integration.test.js +1147 -0
  134. package/tests/widget-refresh-intervals.test.js +284 -0
  135. package/tests/worker-pool.test.js +522 -0
@@ -0,0 +1,584 @@
1
+ /**
2
+ * Dashboard auto-save module for periodic state persistence
3
+ * Automatically saves dashboard state at configurable intervals
4
+ * and on important state changes
5
+ */
6
+
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import os from 'os';
10
+ import logger from './logger.js';
11
+ import { setSecurePermissionsSync, isValidPath } from './security.js';
12
+ import { PATHS } from './config.js';
13
+
14
+ /**
15
+ * Validate a file path for writing
16
+ * @param {string} filePath - Path to validate
17
+ * @returns {Object} Validation result with valid, resolvedPath, error
18
+ */
19
+ function validateFilePath(filePath) {
20
+ if (!filePath || typeof filePath !== 'string') {
21
+ return { valid: false, error: 'Path must be a non-empty string' };
22
+ }
23
+
24
+ // Expand tilde to home directory
25
+ const resolvedPath = filePath.startsWith('~')
26
+ ? path.join(os.homedir(), filePath.slice(1))
27
+ : path.resolve(filePath);
28
+
29
+ // Check for path traversal - allow home directory and temp directories
30
+ const homeDir = os.homedir();
31
+ const tempDirs = ['/tmp', os.tmpdir()];
32
+ const isInAllowedDir = resolvedPath.startsWith(homeDir) ||
33
+ tempDirs.some(tmpDir => resolvedPath.startsWith(tmpDir));
34
+ if (!isInAllowedDir) {
35
+ return { valid: false, error: 'Path must be within home or temp directory' };
36
+ }
37
+
38
+ if (!isValidPath(resolvedPath)) {
39
+ return { valid: false, error: 'Invalid path characters' };
40
+ }
41
+
42
+ return { valid: true, resolvedPath };
43
+ }
44
+
45
+ /**
46
+ * AutoSaveManager class - handles automatic state persistence
47
+ */
48
+ export class AutoSaveManager {
49
+ /**
50
+ * Create an AutoSaveManager instance
51
+ * @param {Object} options - Configuration options
52
+ * @param {number} options.intervalMs - Auto-save interval in milliseconds (default: 30000)
53
+ * @param {boolean} options.enabled - Whether auto-save is enabled (default: true)
54
+ * @param {string} options.statePath - Path to save state file (default: ~/.openclaw/dashboard-state.json)
55
+ * @param {Function} options.getState - Callback to get current state object
56
+ * @param {Function} options.getSettings - Callback to get current settings
57
+ * @param {Function} options.saveSettings - Callback to save settings
58
+ */
59
+ constructor(options = {}) {
60
+ this.intervalMs = options.intervalMs || 30000; // Default 30 seconds
61
+ this.enabled = options.enabled !== false; // Default true
62
+ this.statePath = options.statePath || PATHS.STATE;
63
+ this.getState = options.getState;
64
+ this.getSettings = options.getSettings;
65
+ this.saveSettings = options.saveSettings;
66
+
67
+ this.timer = null;
68
+ this.isDirty = false;
69
+ this.lastSaveTime = 0;
70
+ this.saveCount = 0;
71
+ this.consecutiveFailures = 0;
72
+ this.maxConsecutiveFailures = 3;
73
+
74
+ // Track state checksum to avoid unnecessary writes
75
+ this.lastStateChecksum = null;
76
+
77
+ // Backup rotation settings
78
+ this.backupEnabled = options.backupEnabled !== false; // Default true
79
+ this.backupCount = options.backupCount || 5;
80
+ this.lastStatsLogTime = 0;
81
+ this.statsLogIntervalMs = options.statsLogIntervalMs || 300000; // 5 minutes
82
+
83
+ // Statistics tracking for debug output
84
+ this.stats = {
85
+ totalBytesWritten: 0,
86
+ totalBackupsCreated: 0,
87
+ totalBackupsCleaned: 0,
88
+ lastBackupPath: null,
89
+ averageSaveTimeMs: 0,
90
+ totalSaveTimeMs: 0
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Create a backup of the current state file before overwriting
96
+ * @param {string} statePath - Path to the state file
97
+ * @returns {string|null} Path to backup file or null if no backup created
98
+ */
99
+ createBackup(statePath) {
100
+ if (!this.backupEnabled) {
101
+ return null;
102
+ }
103
+
104
+ try {
105
+ // Only backup if file exists and has content
106
+ if (!fs.existsSync(statePath)) {
107
+ return null;
108
+ }
109
+
110
+ const stats = fs.statSync(statePath);
111
+ if (stats.size === 0) {
112
+ return null;
113
+ }
114
+
115
+ // Create backup with timestamp suffix (including milliseconds for uniqueness)
116
+ const now = new Date();
117
+ let timestamp = now.toISOString().replace(/[:.]/g, '-');
118
+
119
+ // Handle rapid saves within same millisecond by adding counter suffix
120
+ const backupBase = `${statePath}.${timestamp}.backup`;
121
+ let backupPath = backupBase;
122
+ let counter = 1;
123
+ while (fs.existsSync(backupPath)) {
124
+ backupPath = `${statePath}.${timestamp}-${counter}.backup`;
125
+ counter++;
126
+ }
127
+
128
+ // Copy current state to backup
129
+ fs.copyFileSync(statePath, backupPath);
130
+ setSecurePermissionsSync(backupPath);
131
+
132
+ this.stats.totalBackupsCreated++;
133
+ this.stats.lastBackupPath = backupPath;
134
+
135
+ logger.debug(`Created state backup: ${path.basename(backupPath)}`);
136
+ return backupPath;
137
+ } catch (err) {
138
+ logger.debug(`Failed to create backup: ${err.message}`);
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Clean up old backup files, keeping only the most recent N
145
+ * @param {string} statePath - Path to the state file (backups are named statePath.*.backup)
146
+ */
147
+ cleanupBackups(statePath) {
148
+ if (!this.backupEnabled || this.backupCount <= 0) {
149
+ return;
150
+ }
151
+
152
+ try {
153
+ const dir = path.dirname(statePath);
154
+ const baseName = path.basename(statePath);
155
+
156
+ // Find all backup files for this state file
157
+ const backups = fs.readdirSync(dir)
158
+ .filter(f => f.startsWith(baseName) && f.endsWith('.backup'))
159
+ .map(f => ({
160
+ name: f,
161
+ path: path.join(dir, f),
162
+ mtime: fs.statSync(path.join(dir, f)).mtime
163
+ }))
164
+ .sort((a, b) => b.mtime - a.mtime); // Newest first
165
+
166
+ // Remove old backups beyond the keep count
167
+ let cleaned = 0;
168
+ for (let i = this.backupCount; i < backups.length; i++) {
169
+ try {
170
+ fs.unlinkSync(backups[i].path);
171
+ cleaned++;
172
+ logger.debug(`Cleaned up old backup: ${backups[i].name}`);
173
+ } catch {
174
+ // Ignore individual cleanup errors
175
+ }
176
+ }
177
+
178
+ if (cleaned > 0) {
179
+ this.stats.totalBackupsCleaned += cleaned;
180
+ logger.debug(`Backup cleanup complete: removed ${cleaned} old backups`);
181
+ }
182
+ } catch (err) {
183
+ logger.debug(`Backup cleanup failed: ${err.message}`);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Log auto-save statistics to debug output for troubleshooting
189
+ */
190
+ logStats() {
191
+ const now = Date.now();
192
+
193
+ // Only log if interval has passed
194
+ if (now - this.lastStatsLogTime < this.statsLogIntervalMs) {
195
+ return;
196
+ }
197
+
198
+ this.lastStatsLogTime = now;
199
+
200
+ // Calculate derived statistics
201
+ const uptimeMs = now - (this.lastSaveTime > 0 ? this.lastSaveTime - (this.saveCount * this.intervalMs) : now);
202
+ const avgSaveTime = this.saveCount > 0 ? (this.stats.totalSaveTimeMs / this.saveCount).toFixed(2) : 0;
203
+ const lastSaveAgo = this.lastSaveTime > 0 ? ((now - this.lastSaveTime) / 1000).toFixed(0) : 'never';
204
+
205
+ // Build stats message
206
+ const statsLines = [
207
+ '=== Auto-Save Statistics ===',
208
+ ` Enabled: ${this.enabled}`,
209
+ ` Interval: ${this.intervalMs}ms`,
210
+ ` Backup rotation: ${this.backupEnabled ? 'on' : 'off'} (keep ${this.backupCount})`,
211
+ ` Saves performed: ${this.saveCount}`,
212
+ ` Consecutive failures: ${this.consecutiveFailures}`,
213
+ ` Total bytes written: ${this.stats.totalBytesWritten.toLocaleString()}`,
214
+ ` Total backups created: ${this.stats.totalBackupsCreated}`,
215
+ ` Total backups cleaned: ${this.stats.totalBackupsCleaned}`,
216
+ ` Average save time: ${avgSaveTime}ms`,
217
+ ` Last save: ${lastSaveAgo}s ago`,
218
+ ` State file: ${this.statePath}`,
219
+ ` Last backup: ${this.stats.lastBackupPath ? path.basename(this.stats.lastBackupPath) : 'none'}`,
220
+ '==========================='
221
+ ];
222
+
223
+ // Log each line
224
+ statsLines.forEach(line => logger.debug(line));
225
+ }
226
+
227
+ /**
228
+ * Start auto-save timer
229
+ */
230
+ start() {
231
+ if (!this.enabled) {
232
+ logger.debug('Auto-save is disabled');
233
+ return;
234
+ }
235
+
236
+ if (this.timer) {
237
+ this.stop();
238
+ }
239
+
240
+ this.timer = setInterval(() => {
241
+ this.performAutoSave();
242
+ }, this.intervalMs);
243
+
244
+ // Unref timer so it doesn't prevent process exit
245
+ if (this.timer.unref) {
246
+ this.timer.unref();
247
+ }
248
+
249
+ logger.info(`Auto-save started (interval: ${this.intervalMs}ms)`);
250
+ }
251
+
252
+ /**
253
+ * Stop auto-save timer
254
+ */
255
+ stop() {
256
+ if (this.timer) {
257
+ clearInterval(this.timer);
258
+ this.timer = null;
259
+ logger.debug('Auto-save stopped');
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Mark state as dirty - triggers save on next interval or immediate save
265
+ * @param {boolean} immediate - Whether to save immediately (default: false)
266
+ */
267
+ markDirty(immediate = false) {
268
+ this.isDirty = true;
269
+
270
+ if (immediate) {
271
+ this.performAutoSave();
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Calculate a simple checksum for state comparison
277
+ * @param {Object} state - State object
278
+ * @returns {string} Checksum string
279
+ */
280
+ calculateChecksum(state) {
281
+ try {
282
+ // Create a copy without the timestamp for comparison
283
+ // Timestamp changes every call but doesn't represent actual state change
284
+ const { timestamp, ...stateWithoutTimestamp } = state;
285
+ // Simple JSON stringification for comparison
286
+ // In production, could use crypto.createHash for more robust checksum
287
+ return JSON.stringify(stateWithoutTimestamp);
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Get current state snapshot
295
+ * @returns {Object} State snapshot
296
+ */
297
+ getStateSnapshot() {
298
+ const snapshot = {
299
+ timestamp: Date.now(),
300
+ settings: null,
301
+ ui: {}
302
+ };
303
+
304
+ // Get settings if callback provided
305
+ if (this.getSettings) {
306
+ snapshot.settings = this.getSettings();
307
+ }
308
+
309
+ // Get additional state if callback provided
310
+ if (this.getState) {
311
+ const state = this.getState();
312
+ if (state) {
313
+ // Include UI state that should persist
314
+ snapshot.ui = {
315
+ selectedSessionIndex: state.selectedSessionIndex || 0,
316
+ paginationOffset: state.paginationOffset || 0,
317
+ sessionSearchQuery: state.sessionSearchQuery || '',
318
+ isSearchMode: state.isSearchMode || false,
319
+ showFavoritesOnly: state.showFavoritesOnly || false,
320
+ focusedWidgetIndex: state.focusedWidgetIndex || -1,
321
+ currentRefreshInterval: state.currentRefreshInterval || 2000,
322
+ };
323
+ }
324
+ }
325
+
326
+ return snapshot;
327
+ }
328
+
329
+ /**
330
+ * Perform the actual auto-save
331
+ * @returns {boolean} Whether save was successful
332
+ */
333
+ performAutoSave() {
334
+ if (!this.enabled) {
335
+ return false;
336
+ }
337
+
338
+ const startTime = Date.now();
339
+
340
+ try {
341
+ const snapshot = this.getStateSnapshot();
342
+ const checksum = this.calculateChecksum(snapshot);
343
+
344
+ // Skip if state hasn't changed
345
+ if (checksum === this.lastStateChecksum && !this.isDirty) {
346
+ return true;
347
+ }
348
+
349
+ // Validate the state path
350
+ const pathValidation = validateFilePath(this.statePath);
351
+ if (!pathValidation.valid) {
352
+ logger.warn(`Auto-save path validation failed: ${pathValidation.error}`);
353
+ return false;
354
+ }
355
+
356
+ // Ensure directory exists
357
+ const dir = pathValidation.resolvedPath.substring(0, pathValidation.resolvedPath.lastIndexOf('/'));
358
+ if (!fs.existsSync(dir)) {
359
+ fs.mkdirSync(dir, { recursive: true });
360
+ }
361
+
362
+ // Create backup before overwriting (only if file exists)
363
+ this.createBackup(pathValidation.resolvedPath);
364
+
365
+ // Write state file
366
+ const jsonData = JSON.stringify(snapshot, null, 2);
367
+ fs.writeFileSync(pathValidation.resolvedPath, jsonData);
368
+
369
+ // Set secure permissions (owner read/write only)
370
+ setSecurePermissionsSync(pathValidation.resolvedPath);
371
+
372
+ // Clean up old backups
373
+ this.cleanupBackups(pathValidation.resolvedPath);
374
+
375
+ // Update tracking
376
+ this.lastStateChecksum = checksum;
377
+ this.isDirty = false;
378
+ this.lastSaveTime = Date.now();
379
+ this.saveCount++;
380
+ this.consecutiveFailures = 0;
381
+
382
+ // Update statistics
383
+ this.stats.totalBytesWritten += Buffer.byteLength(jsonData, 'utf8');
384
+ const saveTime = Date.now() - startTime;
385
+ this.stats.totalSaveTimeMs += saveTime;
386
+ this.stats.averageSaveTimeMs = this.stats.totalSaveTimeMs / this.saveCount;
387
+
388
+ // Log stats periodically for troubleshooting
389
+ this.logStats();
390
+
391
+ logger.debug(`Auto-save completed successfully (${saveTime}ms)`);
392
+ return true;
393
+ } catch (err) {
394
+ this.consecutiveFailures++;
395
+ logger.error(`Auto-save failed (${this.consecutiveFailures}/${this.maxConsecutiveFailures}): ${err.message}`);
396
+
397
+ // Disable auto-save if too many consecutive failures
398
+ if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
399
+ logger.error('Auto-save disabled due to repeated failures');
400
+ this.enabled = false;
401
+ this.stop();
402
+ }
403
+
404
+ return false;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Perform immediate save (e.g., on shutdown)
410
+ * @returns {boolean} Whether save was successful
411
+ */
412
+ saveNow() {
413
+ return this.performAutoSave();
414
+ }
415
+
416
+ /**
417
+ * Get auto-save statistics
418
+ * @returns {Object} Statistics object
419
+ */
420
+ getStats() {
421
+ return {
422
+ enabled: this.enabled,
423
+ intervalMs: this.intervalMs,
424
+ lastSaveTime: this.lastSaveTime,
425
+ saveCount: this.saveCount,
426
+ consecutiveFailures: this.consecutiveFailures,
427
+ isDirty: this.isDirty,
428
+ statePath: this.statePath,
429
+ // Extended statistics for troubleshooting
430
+ backupEnabled: this.backupEnabled,
431
+ backupCount: this.backupCount,
432
+ totalBytesWritten: this.stats.totalBytesWritten,
433
+ totalBackupsCreated: this.stats.totalBackupsCreated,
434
+ totalBackupsCleaned: this.stats.totalBackupsCleaned,
435
+ lastBackupPath: this.stats.lastBackupPath,
436
+ averageSaveTimeMs: this.stats.averageSaveTimeMs,
437
+ totalSaveTimeMs: this.stats.totalSaveTimeMs
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Update configuration
443
+ * @param {Object} options - New configuration options
444
+ */
445
+ updateConfig(options = {}) {
446
+ if (options.intervalMs !== undefined) {
447
+ this.intervalMs = options.intervalMs;
448
+ }
449
+ if (options.enabled !== undefined) {
450
+ this.enabled = options.enabled;
451
+ }
452
+
453
+ // Restart if running and config changed
454
+ if (this.timer) {
455
+ this.stop();
456
+ this.start();
457
+ }
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Load saved dashboard state
463
+ * @param {string} statePath - Path to state file
464
+ * @returns {Object|null} Loaded state or null if not found/invalid
465
+ */
466
+ export function loadDashboardState(statePath) {
467
+ try {
468
+ const pathValidation = validateFilePath(statePath);
469
+ if (!pathValidation.valid) {
470
+ logger.warn(`State path validation failed: ${pathValidation.error}`);
471
+ return null;
472
+ }
473
+
474
+ if (!fs.existsSync(pathValidation.resolvedPath)) {
475
+ return null;
476
+ }
477
+
478
+ const data = fs.readFileSync(pathValidation.resolvedPath, 'utf8');
479
+ const state = JSON.parse(data);
480
+
481
+ logger.info('Loaded dashboard state from ' + pathValidation.resolvedPath);
482
+ return state;
483
+ } catch (err) {
484
+ logger.warn('Failed to load dashboard state: ' + err.message);
485
+ return null;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Restore UI state from saved state
491
+ * @param {Object} savedState - Saved state object
492
+ * @param {Object} dashboard - Dashboard instance to restore state to
493
+ */
494
+ export function restoreDashboardState(savedState, dashboard) {
495
+ if (!savedState || !savedState.ui) {
496
+ return false;
497
+ }
498
+
499
+ try {
500
+ const ui = savedState.ui;
501
+
502
+ // Restore session selection
503
+ if (ui.selectedSessionIndex !== undefined) {
504
+ dashboard.selectedSessionIndex = ui.selectedSessionIndex;
505
+ }
506
+ if (ui.paginationOffset !== undefined) {
507
+ dashboard.paginationOffset = ui.paginationOffset;
508
+ }
509
+
510
+ // Restore search state
511
+ if (ui.sessionSearchQuery !== undefined) {
512
+ dashboard.sessionSearchQuery = ui.sessionSearchQuery;
513
+ if (dashboard.sessionSearchQuery) {
514
+ dashboard.isSearchMode = true;
515
+ }
516
+ }
517
+ if (ui.isSearchMode !== undefined) {
518
+ dashboard.isSearchMode = ui.isSearchMode;
519
+ }
520
+
521
+ // Restore favorites filter
522
+ if (ui.showFavoritesOnly !== undefined) {
523
+ dashboard.showFavoritesOnly = ui.showFavoritesOnly;
524
+ }
525
+
526
+ // Restore widget focus
527
+ if (ui.focusedWidgetIndex !== undefined) {
528
+ dashboard.focusedWidgetIndex = ui.focusedWidgetIndex;
529
+ }
530
+
531
+ // Restore refresh interval
532
+ if (ui.currentRefreshInterval !== undefined) {
533
+ dashboard.currentRefreshInterval = ui.currentRefreshInterval;
534
+ }
535
+
536
+ logger.info('Dashboard state restored');
537
+ return true;
538
+ } catch (err) {
539
+ logger.error('Failed to restore dashboard state: ' + err.message);
540
+ return false;
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Clean up old state files (keep last N)
546
+ * @param {string} stateDir - Directory containing state files
547
+ * @param {number} keepCount - Number of state files to keep (default: 5)
548
+ */
549
+ export function cleanupOldStateFiles(stateDir, keepCount = 5) {
550
+ try {
551
+ if (!fs.existsSync(stateDir)) {
552
+ return;
553
+ }
554
+
555
+ const files = fs.readdirSync(stateDir)
556
+ .filter(f => f.startsWith('dashboard-state') && f.endsWith('.json'))
557
+ .map(f => ({
558
+ name: f,
559
+ path: `${stateDir}/${f}`,
560
+ mtime: fs.statSync(`${stateDir}/${f}`).mtime
561
+ }))
562
+ .sort((a, b) => b.mtime - a.mtime);
563
+
564
+ // Remove old files
565
+ for (let i = keepCount; i < files.length; i++) {
566
+ try {
567
+ fs.unlinkSync(files[i].path);
568
+ logger.debug(`Cleaned up old state file: ${files[i].name}`);
569
+ } catch {
570
+ // Ignore cleanup errors
571
+ }
572
+ }
573
+ } catch {
574
+ // Ignore cleanup errors
575
+ }
576
+ }
577
+
578
+ // Default export
579
+ export default {
580
+ AutoSaveManager,
581
+ loadDashboardState,
582
+ restoreDashboardState,
583
+ cleanupOldStateFiles
584
+ };