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.
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 +5236 -566
  40. package/jest.config.js +11 -0
  41. package/man/clawdash.1 +276 -0
  42. package/package.json +54 -5
  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 +1941 -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 +1057 -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,401 @@
1
+ /**
2
+ * Config Watcher Module
3
+ * Watches configuration files for changes and triggers hot-reload
4
+ * Supports debouncing to avoid multiple reloads on rapid file changes
5
+ */
6
+
7
+ import { watch, watchFile, unwatchFile, existsSync, readdirSync, readFileSync } from 'fs';
8
+ import { EventEmitter } from 'events';
9
+ import { join } from 'path';
10
+ import logger from './logger.js';
11
+
12
+ /**
13
+ * Default watcher options
14
+ */
15
+ export const DEFAULT_WATCHER_OPTIONS = {
16
+ debounceMs: 500, // Debounce interval for file changes
17
+ persistent: true, // Keep process running while watching
18
+ encoding: 'utf8', // File encoding
19
+ usePolling: false, // Use polling instead of native events (more reliable on some systems)
20
+ pollInterval: 1000, // Polling interval when usePolling is true
21
+ ignoreInitial: true, // Ignore the initial 'add' event
22
+ };
23
+
24
+ /**
25
+ * ConfigWatcher class
26
+ * Watches one or more config files for changes and emits reload events
27
+ */
28
+ export class ConfigWatcher extends EventEmitter {
29
+ constructor(options = {}) {
30
+ super();
31
+ this.options = { ...DEFAULT_WATCHER_OPTIONS, ...options };
32
+ this.watchers = new Map(); // filepath -> FSWatcher
33
+ this.pollWatchers = new Map(); // filepath -> poll handle
34
+ this.lastModified = new Map(); // filepath -> timestamp
35
+ this.debounceTimers = new Map(); // filepath -> timer handle
36
+ this.watchedFiles = new Set(); // Set of watched file paths
37
+ this.isRunning = false;
38
+ }
39
+
40
+ /**
41
+ * Start watching a config file
42
+ * @param {string} filePath - Path to the file to watch
43
+ * @param {Object} options - Optional override options
44
+ * @returns {boolean} True if successfully started watching
45
+ */
46
+ watchFile(filePath, options = {}) {
47
+ if (!filePath || typeof filePath !== 'string') {
48
+ logger.error('ConfigWatcher: Invalid file path provided');
49
+ return false;
50
+ }
51
+
52
+ if (this.watchers.has(filePath)) {
53
+ logger.debug(`ConfigWatcher: Already watching ${filePath}`);
54
+ return true;
55
+ }
56
+
57
+ const opts = { ...this.options, ...options };
58
+
59
+ // Check if file exists
60
+ if (!existsSync(filePath)) {
61
+ logger.warn(`ConfigWatcher: File not found: ${filePath}`);
62
+ return false;
63
+ }
64
+
65
+ try {
66
+ if (opts.usePolling) {
67
+ this._startPolling(filePath, opts);
68
+ } else {
69
+ this._startNativeWatch(filePath, opts);
70
+ }
71
+
72
+ this.watchedFiles.add(filePath);
73
+ this.lastModified.set(filePath, Date.now());
74
+ this.isRunning = true;
75
+
76
+ logger.info(`ConfigWatcher: Started watching ${filePath}`);
77
+ return true;
78
+ } catch (err) {
79
+ logger.error(`ConfigWatcher: Failed to watch ${filePath}: ${err.message}`);
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Stop watching a config file
86
+ * @param {string} filePath - Path to stop watching
87
+ */
88
+ unwatchFile(filePath) {
89
+ if (!this.watchers.has(filePath) && !this.pollWatchers.has(filePath)) {
90
+ return;
91
+ }
92
+
93
+ // Clear debounce timer if exists
94
+ const timer = this.debounceTimers.get(filePath);
95
+ if (timer) {
96
+ clearTimeout(timer);
97
+ this.debounceTimers.delete(filePath);
98
+ }
99
+
100
+ // Close native watcher
101
+ const watcher = this.watchers.get(filePath);
102
+ if (watcher) {
103
+ watcher.close();
104
+ this.watchers.delete(filePath);
105
+ }
106
+
107
+ // Stop polling watcher
108
+ if (this.pollWatchers.has(filePath)) {
109
+ unwatchFile(filePath);
110
+ this.pollWatchers.delete(filePath);
111
+ }
112
+
113
+ this.watchedFiles.delete(filePath);
114
+ this.lastModified.delete(filePath);
115
+
116
+ logger.info(`ConfigWatcher: Stopped watching ${filePath}`);
117
+
118
+ if (this.watchers.size === 0 && this.pollWatchers.size === 0) {
119
+ this.isRunning = false;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Start watching multiple files
125
+ * @param {string[]} filePaths - Array of file paths to watch
126
+ * @returns {Object} Results with successful and failed paths
127
+ */
128
+ watchFiles(filePaths) {
129
+ const results = { successful: [], failed: [] };
130
+
131
+ for (const filePath of filePaths) {
132
+ if (this.watchFile(filePath)) {
133
+ results.successful.push(filePath);
134
+ } else {
135
+ results.failed.push(filePath);
136
+ }
137
+ }
138
+
139
+ return results;
140
+ }
141
+
142
+ /**
143
+ * Stop watching all files
144
+ */
145
+ unwatchAll() {
146
+ for (const filePath of this.watchedFiles) {
147
+ this.unwatchFile(filePath);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Get list of watched files
153
+ * @returns {string[]} Array of watched file paths
154
+ */
155
+ getWatchedFiles() {
156
+ return Array.from(this.watchedFiles);
157
+ }
158
+
159
+ /**
160
+ * Check if a file is being watched
161
+ * @param {string} filePath - Path to check
162
+ * @returns {boolean} True if being watched
163
+ */
164
+ isWatching(filePath) {
165
+ return this.watchedFiles.has(filePath);
166
+ }
167
+
168
+ /**
169
+ * Start native file watcher (fs.watch)
170
+ * @private
171
+ */
172
+ _startNativeWatch(filePath, opts) {
173
+ const watcher = watch(filePath, { persistent: opts.persistent, encoding: opts.encoding });
174
+
175
+ watcher.on('change', (eventType) => {
176
+ if (eventType === 'change') {
177
+ this._handleChange(filePath, opts);
178
+ }
179
+ });
180
+
181
+ watcher.on('error', (err) => {
182
+ logger.error(`ConfigWatcher: Watcher error for ${filePath}: ${err.message}`);
183
+ this.emit('error', { filePath, error: err });
184
+ });
185
+
186
+ watcher.on('close', () => {
187
+ this.watchers.delete(filePath);
188
+ if (this.watchers.size === 0 && this.pollWatchers.size === 0) {
189
+ this.isRunning = false;
190
+ }
191
+ });
192
+
193
+ this.watchers.set(filePath, watcher);
194
+ }
195
+
196
+ /**
197
+ * Start polling-based watcher (fs.watchFile)
198
+ * @private
199
+ */
200
+ _startPolling(filePath, opts) {
201
+ watchFile(filePath, { persistent: opts.persistent, interval: opts.pollInterval }, (curr, prev) => {
202
+ if (curr.mtimeMs !== prev.mtimeMs) {
203
+ this._handleChange(filePath, opts);
204
+ }
205
+ });
206
+
207
+ this.pollWatchers.set(filePath, true);
208
+ }
209
+
210
+ /**
211
+ * Handle file change with debouncing
212
+ * @private
213
+ */
214
+ _handleChange(filePath, opts) {
215
+ const now = Date.now();
216
+ const last = this.lastModified.get(filePath) || 0;
217
+
218
+ // Update last modified time
219
+ this.lastModified.set(filePath, now);
220
+
221
+ // Clear existing debounce timer
222
+ const existingTimer = this.debounceTimers.get(filePath);
223
+ if (existingTimer) {
224
+ clearTimeout(existingTimer);
225
+ }
226
+
227
+ // Set new debounce timer
228
+ const timer = setTimeout(() => {
229
+ this.debounceTimers.delete(filePath);
230
+ this._emitReload(filePath);
231
+ }, opts.debounceMs);
232
+
233
+ this.debounceTimers.set(filePath, timer);
234
+ }
235
+
236
+ /**
237
+ * Emit reload event for a file
238
+ * @private
239
+ */
240
+ _emitReload(filePath) {
241
+ logger.info(`ConfigWatcher: File changed: ${filePath}`);
242
+ this.emit('reload', { filePath, timestamp: Date.now() });
243
+ }
244
+
245
+ /**
246
+ * Get watcher statistics
247
+ * @returns {Object} Stats object
248
+ */
249
+ getStats() {
250
+ return {
251
+ isRunning: this.isRunning,
252
+ watchedFiles: this.watchedFiles.size,
253
+ nativeWatchers: this.watchers.size,
254
+ pollWatchers: this.pollWatchers.size,
255
+ pendingDebounces: this.debounceTimers.size,
256
+ };
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Create a config preprocessor for widget config hot-reload
262
+ * @param {Object} options - Options for the watcher
263
+ * @returns {Object} ConfigWatcher instance
264
+ */
265
+ export function createConfigWatcher(options = {}) {
266
+ return new ConfigWatcher(options);
267
+ }
268
+
269
+ /**
270
+ * Watch dashboard settings file and trigger callback on change
271
+ * @param {string} settingsPath - Path to settings file
272
+ * @param {Function} callback - Callback function(settings) to call on change
273
+ * @param {Object} options - Watcher options
274
+ * @returns {ConfigWatcher|null} Watcher instance or null on failure
275
+ */
276
+ export function watchSettingsFile(settingsPath, callback, options = {}) {
277
+ if (!existsSync(settingsPath)) {
278
+ logger.warn(`ConfigWatcher: Settings file not found: ${settingsPath}`);
279
+ return null;
280
+ }
281
+
282
+ const watcher = createConfigWatcher(options);
283
+
284
+ watcher.on('reload', async ({ filePath }) => {
285
+ try {
286
+ const content = readFileSync(filePath, 'utf8');
287
+ const settings = JSON.parse(content);
288
+
289
+ logger.info(`ConfigWatcher: Settings reloaded from ${filePath}`);
290
+
291
+ // Call the callback with new settings
292
+ if (typeof callback === 'function') {
293
+ await callback(settings, filePath);
294
+ }
295
+ } catch (err) {
296
+ logger.error(`ConfigWatcher: Failed to reload settings: ${err.message}`);
297
+ watcher.emit('error', { filePath, error: err });
298
+ }
299
+ });
300
+
301
+ watcher.on('error', ({ filePath, error }) => {
302
+ logger.error(`ConfigWatcher: Error for ${filePath}: ${error.message}`);
303
+ });
304
+
305
+ if (!watcher.watchFile(settingsPath)) {
306
+ return null;
307
+ }
308
+
309
+ return watcher;
310
+ }
311
+
312
+ /**
313
+ * Watch plugin config directory for changes
314
+ * @param {string} pluginsDir - Path to plugins directory
315
+ * @param {Function} callback - Callback function(pluginId, manifest) to call on change
316
+ * @param {Object} options - Watcher options
317
+ * @returns {ConfigWatcher|null} Watcher instance or null on failure
318
+ */
319
+ export function watchPluginsDirectory(pluginsDir, callback, options = {}) {
320
+ if (!existsSync(pluginsDir)) {
321
+ logger.warn(`ConfigWatcher: Plugins directory not found: ${pluginsDir}`);
322
+ return null;
323
+ }
324
+
325
+ const watcher = createConfigWatcher(options);
326
+
327
+ watcher.on('reload', async ({ filePath }) => {
328
+ try {
329
+ // Determine which plugin was modified
330
+ const relativePath = filePath.replace(pluginsDir, '');
331
+ const pluginId = relativePath.split('/')[1] || relativePath.split('\\')[1];
332
+
333
+ if (!pluginId) {
334
+ logger.debug(`ConfigWatcher: Could not determine plugin ID for ${filePath}`);
335
+ return;
336
+ }
337
+
338
+ // Read the manifest file
339
+ const manifestPath = join(pluginsDir, pluginId, 'plugin.json');
340
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
341
+
342
+ logger.info(`ConfigWatcher: Plugin ${pluginId} config changed`);
343
+
344
+ // Call the callback with plugin ID and manifest
345
+ if (typeof callback === 'function') {
346
+ await callback(pluginId, manifest, filePath);
347
+ }
348
+ } catch (err) {
349
+ logger.error(`ConfigWatcher: Failed to handle plugin change: ${err.message}`);
350
+ watcher.emit('error', { filePath, error: err });
351
+ }
352
+ });
353
+
354
+ // Watch all plugin.json files in the plugins directory
355
+ try {
356
+ const entries = readdirSync(pluginsDir, { withFileTypes: true });
357
+ let watchedCount = 0;
358
+
359
+ for (const entry of entries) {
360
+ if (!entry.isDirectory()) continue;
361
+
362
+ const pluginPath = join(pluginsDir, entry.name);
363
+ const manifestPath = join(pluginPath, 'plugin.json');
364
+
365
+ if (existsSync(manifestPath)) {
366
+ if (watcher.watchFile(manifestPath)) {
367
+ watchedCount++;
368
+ }
369
+ }
370
+ }
371
+
372
+ if (watchedCount === 0) {
373
+ logger.info(`ConfigWatcher: No plugin configs found in ${pluginsDir}`);
374
+ } else {
375
+ logger.info(`ConfigWatcher: Watching ${watchedCount} plugin configs`);
376
+ }
377
+ } catch (err) {
378
+ logger.error(`ConfigWatcher: Failed to scan plugins directory: ${err.message}`);
379
+ watcher.unwatchAll();
380
+ return null;
381
+ }
382
+
383
+ return watcher;
384
+ }
385
+
386
+ // Singleton instance for simple use cases
387
+ let defaultWatcher = null;
388
+
389
+ /**
390
+ * Get the default config watcher instance
391
+ * @param {Object} options - Options for creating the watcher if needed
392
+ * @returns {ConfigWatcher} Default watcher instance
393
+ */
394
+ export function getConfigWatcher(options) {
395
+ if (!defaultWatcher) {
396
+ defaultWatcher = new ConfigWatcher(options);
397
+ }
398
+ return defaultWatcher;
399
+ }
400
+
401
+ export default ConfigWatcher;