claw-dashboard 1.9.0 → 2.0.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/.c8rc.json +38 -0
- package/.dockerignore +68 -0
- package/.github/dependabot.yml +45 -0
- package/.github/pull_request_template.md +39 -0
- package/.github/workflows/ci.yml +147 -0
- package/.github/workflows/release.yml +40 -0
- package/.github/workflows/security.yml +84 -0
- package/.husky/pre-commit +1 -0
- package/.planning/codebase/ARCHITECTURE.md +206 -0
- package/.planning/codebase/INTEGRATIONS.md +150 -0
- package/.planning/codebase/STACK.md +122 -0
- package/.planning/codebase/STRUCTURE.md +201 -0
- package/CHANGELOG.md +293 -0
- package/CONTRIBUTING.md +378 -0
- package/Dockerfile +54 -0
- package/FEATURES.md +195 -21
- package/README.md +83 -6
- package/TODO.md +28 -0
- package/build-cjs.js +127 -0
- package/cjs-shim.js +13 -0
- package/dist/clawdash +1729 -0
- package/dist/clawdash.meta.json +2236 -0
- package/dist/widgets.cjs +6511 -0
- package/docker-compose.yml +67 -0
- package/docs/API.md +1050 -0
- package/docs/PLUGINS.md +1504 -0
- package/esbuild.config.js +158 -0
- package/eslint.config.js +56 -0
- package/examples/plugins/README.md +122 -0
- package/examples/plugins/api-status/index.js +294 -0
- package/examples/plugins/api-status/plugin.json +19 -0
- package/examples/plugins/hello-world/index.js +104 -0
- package/examples/plugins/hello-world/plugin.json +15 -0
- package/examples/plugins/system-metrics-chart/index.js +339 -0
- package/examples/plugins/system-metrics-chart/plugin.json +18 -0
- package/examples/plugins/weather-widget/index.js +136 -0
- package/examples/plugins/weather-widget/plugin.json +19 -0
- package/index.cjs +23005 -0
- package/index.js +5236 -566
- package/jest.config.js +11 -0
- package/man/clawdash.1 +276 -0
- package/package.json +54 -5
- package/schemas/plugin-manifest.json +128 -0
- package/scripts/release.js +595 -0
- package/src/alerts.js +693 -0
- package/src/auto-save.js +584 -0
- package/src/cache.js +390 -0
- package/src/checksum.js +146 -0
- package/src/cli/args.js +110 -0
- package/src/cli/export-schedule.js +423 -0
- package/src/cli/export-snapshot.js +138 -0
- package/src/cli/help.js +69 -0
- package/src/cli/import-snapshot.js +230 -0
- package/src/cli/index.js +14 -0
- package/src/cli/list-templates.js +69 -0
- package/src/cli/validate-config.js +76 -0
- package/src/cli/validate-plugin.js +187 -0
- package/src/cli/version.js +15 -0
- package/src/config-validator.js +586 -0
- package/src/config-watcher.js +401 -0
- package/src/config.js +684 -0
- package/src/container-detector.js +499 -0
- package/src/database.js +734 -0
- package/src/differential-render.js +327 -0
- package/src/errors.js +169 -0
- package/src/export-scheduler.js +581 -0
- package/src/gateway-manager.js +685 -0
- package/src/hints.js +371 -0
- package/src/loading-states.js +509 -0
- package/src/logger.js +185 -0
- package/src/memory-pressure.js +467 -0
- package/src/performance-monitor.js +374 -0
- package/src/plugin-errors.js +586 -0
- package/src/plugin-manifest-validator.js +219 -0
- package/src/plugin-reload.js +481 -0
- package/src/plugin-scaffold.js +1941 -0
- package/src/plugin-validator.js +284 -0
- package/src/retry.js +218 -0
- package/src/security.js +860 -0
- package/src/snapshot.js +372 -0
- package/src/splash.js +115 -0
- package/src/theme-selector.js +411 -0
- package/src/themes.js +874 -0
- package/src/transitions.js +534 -0
- package/src/types.d.ts +174 -0
- package/src/validation.js +926 -0
- package/src/web-server.js +885 -0
- package/src/widgets/builtin-widgets.js +1057 -0
- package/src/widgets/config-processor.js +401 -0
- package/src/widgets/dependency-resolver.js +596 -0
- package/src/widgets/index.js +79 -0
- package/src/widgets/plugin-api.js +845 -0
- package/src/widgets/widget-error-boundary.js +773 -0
- package/src/widgets/widget-error-isolation.js +551 -0
- package/src/widgets/widget-loader.js +1437 -0
- package/src/workers/system-worker.js +130 -0
- package/src/workers/worker-pool.js +633 -0
- package/tests/alerts.test.js +437 -0
- package/tests/auto-save.test.js +529 -0
- package/tests/cache.test.js +317 -0
- package/tests/cli.test.js +351 -0
- package/tests/command-palette.test.js +320 -0
- package/tests/config-processor.test.js +452 -0
- package/tests/config-validator.test.js +710 -0
- package/tests/config-watcher.test.js +594 -0
- package/tests/config.test.js +755 -0
- package/tests/database.test.js +438 -0
- package/tests/errors.test.js +189 -0
- package/tests/example-plugins.test.js +624 -0
- package/tests/integration.test.js +1321 -0
- package/tests/loading-states.test.js +300 -0
- package/tests/manifest-validation-on-load.test.js +671 -0
- package/tests/performance-monitor.test.js +190 -0
- package/tests/plugin-api-rate-limit.test.js +302 -0
- package/tests/plugin-errors.test.js +311 -0
- package/tests/plugin-lifecycle-e2e.test.js +1036 -0
- package/tests/plugin-reload.test.js +764 -0
- package/tests/rate-limiter.test.js +539 -0
- package/tests/retry.test.js +308 -0
- package/tests/security.test.js +411 -0
- package/tests/settings-modal.test.js +226 -0
- package/tests/theme-selector.test.js +57 -0
- package/tests/utils.js +242 -0
- package/tests/utils.test.js +317 -0
- package/tests/validate-plugin-cli.test.js +359 -0
- package/tests/validation.test.js +837 -0
- package/tests/web-server.test.js +646 -0
- package/tests/widget-arrange-mode.test.js +183 -0
- package/tests/widget-config-hot-reload.test.js +465 -0
- package/tests/widget-dependency.test.js +591 -0
- package/tests/widget-error-boundary.test.js +749 -0
- package/tests/widget-error-isolation.test.js +469 -0
- package/tests/widget-integration.test.js +1147 -0
- package/tests/widget-refresh-intervals.test.js +284 -0
- package/tests/worker-pool.test.js +522 -0
package/src/auto-save.js
ADDED
|
@@ -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
|
+
};
|