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,845 @@
1
+ /**
2
+ * Plugin API for Claw Dashboard
3
+ * Provides a stable API for third-party widget development
4
+ */
5
+
6
+ import EventEmitter from 'events';
7
+ import blessed from 'blessed';
8
+ import logger from '../logger.js';
9
+ import { getWidgetLoader } from './widget-loader.js';
10
+ import { RateLimiter } from '../alerts.js';
11
+ import config from '../config.js';
12
+
13
+ /**
14
+ * Plugin API version - follows semver
15
+ */
16
+ export const PLUGIN_API_VERSION = '1.0.0';
17
+
18
+ /**
19
+ * Default rate limit configuration for plugin API calls
20
+ */
21
+ const DEFAULT_API_RATE_LIMIT = {
22
+ enabled: true,
23
+ windowMs: 60000, // 1 minute window
24
+ maxCalls: 100, // Max 100 calls per minute per category
25
+ alwaysAllowCritical: false
26
+ };
27
+
28
+ /**
29
+ * Plugin API class - provides stable interface for widgets
30
+ */
31
+ export class PluginAPI extends EventEmitter {
32
+ constructor(options = {}) {
33
+ super();
34
+ this.version = PLUGIN_API_VERSION;
35
+ this.dashboardVersion = options.dashboardVersion || 'unknown';
36
+ this.screen = options.screen || null;
37
+ this.dataProvider = options.dataProvider || null;
38
+ this.settings = options.settings || {};
39
+
40
+ // Extension points
41
+ this.extensions = new Map();
42
+ this.hooks = new Map();
43
+ this.providers = new Map();
44
+
45
+ // Rate limiter for API calls
46
+ this.rateLimiter = new RateLimiter({
47
+ enabled: options.rateLimit?.enabled ?? DEFAULT_API_RATE_LIMIT.enabled,
48
+ windowMs: options.rateLimit?.windowMs ?? DEFAULT_API_RATE_LIMIT.windowMs,
49
+ maxAlerts: options.rateLimit?.maxCalls ?? DEFAULT_API_RATE_LIMIT.maxCalls,
50
+ alwaysAllowCritical: options.rateLimit?.alwaysAllowCritical ?? DEFAULT_API_RATE_LIMIT.alwaysAllowCritical
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Check if an API call should be rate-limited
56
+ * @param {string} category - API call category (getData, executeExtension, getMetrics)
57
+ * @param {string} [level] - Call level for rate limiting
58
+ * @returns {object} Rate limit result with allowed boolean and reason
59
+ * @private
60
+ */
61
+ _checkRateLimit(category, level = 'warning') {
62
+ const result = this.rateLimiter.checkAndRecord(category, level);
63
+ if (!result.allowed) {
64
+ logger.debug(`[PluginAPI] Rate limited: ${category} - ${result.reason}`);
65
+ }
66
+ return result;
67
+ }
68
+
69
+ /**
70
+ * Get the rate limiter instance for custom rate limiting
71
+ * @returns {RateLimiter} The rate limiter instance
72
+ */
73
+ getRateLimiter() {
74
+ return this.rateLimiter;
75
+ }
76
+
77
+ /**
78
+ * Configure the API rate limiter
79
+ * @param {object} options - Rate limit options
80
+ * @param {boolean} [options.enabled] - Enable/disable rate limiting
81
+ * @param {number} [options.windowMs] - Time window in milliseconds
82
+ * @param {number} [options.maxCalls] - Maximum calls per window
83
+ * @param {boolean} [options.alwaysAllowCritical] - Allow critical calls through
84
+ */
85
+ configureRateLimit(options) {
86
+ // Map maxCalls to maxAlerts for RateLimiter compatibility
87
+ const mappedOptions = { ...options };
88
+ if (options.maxCalls !== undefined) {
89
+ mappedOptions.maxAlerts = options.maxCalls;
90
+ delete mappedOptions.maxCalls;
91
+ }
92
+ this.rateLimiter.configure(mappedOptions);
93
+ logger.info(`[PluginAPI] Rate limiter configured: ${JSON.stringify(options)}`);
94
+ }
95
+
96
+ /**
97
+ * Get current rate limit status
98
+ * @returns {object} Rate limit status
99
+ */
100
+ getRateLimitStatus() {
101
+ return this.rateLimiter.getStatus();
102
+ }
103
+
104
+ /**
105
+ * Register an extension point that plugins can hook into
106
+ * @param {string} name - Extension point name
107
+ * @param {Object} options - Extension options
108
+ */
109
+ registerExtensionPoint(name, options = {}) {
110
+ if (this.extensions.has(name)) {
111
+ logger.warn(`Extension point '${name}' already registered`);
112
+ return this;
113
+ }
114
+
115
+ this.extensions.set(name, {
116
+ name,
117
+ description: options.description || '',
118
+ handlers: [],
119
+ multiple: options.multiple !== false, // default true
120
+ required: options.required || [],
121
+ validator: options.validator || null,
122
+ });
123
+
124
+ logger.debug(`Extension point '${name}' registered`);
125
+ return this;
126
+ }
127
+
128
+ /**
129
+ * Add a handler to an extension point
130
+ * @param {string} extensionName - Extension point name
131
+ * @param {Function} handler - Handler function
132
+ * @param {Object} metadata - Handler metadata
133
+ */
134
+ extend(extensionName, handler, metadata = {}) {
135
+ const extension = this.extensions.get(extensionName);
136
+ if (!extension) {
137
+ throw new Error(`Extension point '${extensionName}' not found`);
138
+ }
139
+
140
+ if (!extension.multiple && extension.handlers.length > 0) {
141
+ throw new Error(`Extension point '${extensionName}' only allows one handler`);
142
+ }
143
+
144
+ const wrappedHandler = {
145
+ id: metadata.id || `handler-${Date.now()}`,
146
+ handler,
147
+ metadata: {
148
+ priority: metadata.priority || 100,
149
+ ...metadata,
150
+ },
151
+ };
152
+
153
+ extension.handlers.push(wrappedHandler);
154
+
155
+ // Sort by priority
156
+ extension.handlers.sort((a, b) => a.metadata.priority - b.metadata.priority);
157
+
158
+ logger.debug(`Handler registered for extension point '${extensionName}'`);
159
+ this.emit('extension:added', { extension: extensionName, handler: wrappedHandler });
160
+
161
+ return () => this.removeExtension(extensionName, wrappedHandler.id);
162
+ }
163
+
164
+ /**
165
+ * Remove an extension handler
166
+ * @param {string} extensionName - Extension point name
167
+ * @param {string} handlerId - Handler ID
168
+ */
169
+ removeExtension(extensionName, handlerId) {
170
+ const extension = this.extensions.get(extensionName);
171
+ if (!extension) return false;
172
+
173
+ const index = extension.handlers.findIndex(h => h.id === handlerId);
174
+ if (index === -1) return false;
175
+
176
+ extension.handlers.splice(index, 1);
177
+ this.emit('extension:removed', { extension: extensionName, handlerId });
178
+
179
+ return true;
180
+ }
181
+
182
+ /**
183
+ * Execute all handlers for an extension point
184
+ * @param {string} extensionName - Extension point name
185
+ * @param {...any} args - Arguments to pass to handlers
186
+ * @returns {Promise<Array>} Results from all handlers
187
+ */
188
+ async executeExtension(extensionName, ...args) {
189
+ // Check rate limit
190
+ const rateResult = this._checkRateLimit('executeExtension');
191
+ if (!rateResult.allowed) {
192
+ const retryAfter = this.rateLimiter.getRetryAfter('executeExtension');
193
+ const error = new Error(`Rate limit exceeded for executeExtension. Retry after ${retryAfter}ms`);
194
+ error.code = 'RATE_LIMIT_EXCEEDED';
195
+ error.retryAfter = retryAfter;
196
+ throw error;
197
+ }
198
+
199
+ const extension = this.extensions.get(extensionName);
200
+ if (!extension) {
201
+ logger.warn(`Extension point '${extensionName}' not found`);
202
+ return [];
203
+ }
204
+
205
+ const results = [];
206
+
207
+ for (const { handler, metadata } of extension.handlers) {
208
+ try {
209
+ const result = await handler(...args);
210
+ results.push({ success: true, result, metadata });
211
+ } catch (err) {
212
+ logger.error(`Extension handler error in '${extensionName}': ${err.message}`);
213
+ results.push({ success: false, error: err.message, metadata });
214
+ }
215
+ }
216
+
217
+ return results;
218
+ }
219
+
220
+ /**
221
+ * Register a data provider
222
+ * @param {string} name - Provider name
223
+ * @param {Function} provider - Provider function
224
+ */
225
+ registerDataProvider(name, provider) {
226
+ if (typeof provider !== 'function') {
227
+ throw new Error('Provider must be a function');
228
+ }
229
+
230
+ this.providers.set(name, provider);
231
+ logger.debug(`Data provider '${name}' registered`);
232
+ this.emit('provider:registered', { name });
233
+
234
+ return this;
235
+ }
236
+
237
+ /**
238
+ * Get data from a provider
239
+ * Rate-limited to prevent excessive provider calls.
240
+ * @param {string} name - Provider name
241
+ * @param {...any} args - Arguments to pass to provider
242
+ * @throws {Error} If rate limit exceeded or provider not found
243
+ */
244
+ async getData(name, ...args) {
245
+ // Check rate limit
246
+ const rateResult = this._checkRateLimit('getData');
247
+ if (!rateResult.allowed) {
248
+ const retryAfter = this.rateLimiter.getRetryAfter('getData');
249
+ const error = new Error(`Rate limit exceeded for getData. Retry after ${retryAfter}ms`);
250
+ error.code = 'RATE_LIMIT_EXCEEDED';
251
+ error.retryAfter = retryAfter;
252
+ throw error;
253
+ }
254
+
255
+ const provider = this.providers.get(name);
256
+ if (!provider) {
257
+ throw new Error(`Data provider '${name}' not found`);
258
+ }
259
+
260
+ try {
261
+ return await provider(...args);
262
+ } catch (err) {
263
+ logger.error(`Data provider '${name}' error: ${err.message}`);
264
+ throw err;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Check if a data provider exists
270
+ * @param {string} name - Provider name
271
+ */
272
+ hasDataProvider(name) {
273
+ return this.providers.has(name);
274
+ }
275
+
276
+ /**
277
+ * Register a hook
278
+ * @param {string} event - Event name
279
+ * @param {Function} handler - Handler function
280
+ * @param {Object} options - Hook options
281
+ */
282
+ addHook(event, handler, options = {}) {
283
+ if (!this.hooks.has(event)) {
284
+ this.hooks.set(event, []);
285
+ }
286
+
287
+ const hooks = this.hooks.get(event);
288
+ const wrapped = {
289
+ id: options.id || `hook-${Date.now()}`,
290
+ handler,
291
+ priority: options.priority || 100,
292
+ once: options.once || false,
293
+ };
294
+
295
+ hooks.push(wrapped);
296
+ hooks.sort((a, b) => a.priority - b.priority);
297
+
298
+ logger.debug(`Hook registered for event '${event}'`);
299
+
300
+ return () => this.removeHook(event, wrapped.id);
301
+ }
302
+
303
+ /**
304
+ * Remove a hook
305
+ * @param {string} event - Event name
306
+ * @param {string} hookId - Hook ID
307
+ */
308
+ removeHook(event, hookId) {
309
+ const hooks = this.hooks.get(event);
310
+ if (!hooks) return false;
311
+
312
+ const index = hooks.findIndex(h => h.id === hookId);
313
+ if (index === -1) return false;
314
+
315
+ hooks.splice(index, 1);
316
+ return true;
317
+ }
318
+
319
+ /**
320
+ * Execute hooks for an event
321
+ * @param {string} event - Event name
322
+ * @param {Object} context - Context object that hooks can modify
323
+ * @param {...any} args - Additional arguments
324
+ */
325
+ async executeHooks(event, context = {}, ...args) {
326
+ const hooks = this.hooks.get(event) || [];
327
+ const toRemove = [];
328
+
329
+ for (const hook of hooks) {
330
+ try {
331
+ await hook.handler(context, ...args);
332
+
333
+ if (hook.once) {
334
+ toRemove.push(hook.id);
335
+ }
336
+ } catch (err) {
337
+ logger.error(`Hook error for event '${event}': ${err.message}`);
338
+ }
339
+ }
340
+
341
+ // Remove one-time hooks
342
+ for (const id of toRemove) {
343
+ this.removeHook(event, id);
344
+ }
345
+
346
+ return context;
347
+ }
348
+
349
+ /**
350
+ * Create a UI component helper
351
+ * @param {string} type - Component type
352
+ * @param {Object} options - Component options
353
+ */
354
+ createComponent(type, options = {}) {
355
+ if (!this.screen) {
356
+ throw new Error('Screen not available - cannot create components');
357
+ }
358
+
359
+ const componentConfig = {
360
+ ...options,
361
+ parent: options.parent || this.screen,
362
+ };
363
+
364
+ switch (type) {
365
+ case 'box':
366
+ return blessed.box(componentConfig);
367
+ case 'text':
368
+ return blessed.text(componentConfig);
369
+ case 'list':
370
+ return blessed.list(componentConfig);
371
+ case 'table':
372
+ return blessed.table(componentConfig);
373
+ case 'line':
374
+ return blessed.line(componentConfig);
375
+ default:
376
+ throw new Error(`Unknown component type: ${type}`);
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Get system metrics
382
+ * @param {string} type - Metric type
383
+ */
384
+ async getMetrics(type) {
385
+ // Check rate limit
386
+ const rateResult = this._checkRateLimit('getMetrics');
387
+ if (!rateResult.allowed) {
388
+ const retryAfter = this.rateLimiter.getRetryAfter('getMetrics');
389
+ const error = new Error(`Rate limit exceeded for getMetrics. Retry after ${retryAfter}ms`);
390
+ error.code = 'RATE_LIMIT_EXCEEDED';
391
+ error.retryAfter = retryAfter;
392
+ throw error;
393
+ }
394
+
395
+ if (this.dataProvider) {
396
+ return this.dataProvider(type);
397
+ }
398
+ return null;
399
+ }
400
+
401
+ /**
402
+ * Subscribe to dashboard events
403
+ * @param {string} event - Event name
404
+ * @param {Function} handler - Event handler
405
+ */
406
+ on(event, handler) {
407
+ super.on(event, handler);
408
+ return () => this.off(event, handler);
409
+ }
410
+
411
+ /**
412
+ * Log a message from a plugin
413
+ * @param {string} pluginId - Plugin identifier
414
+ * @param {string} level - Log level
415
+ * @param {string} message - Message
416
+ */
417
+ log(pluginId, level, message) {
418
+ const levels = ['debug', 'info', 'warn', 'error'];
419
+ if (!levels.includes(level)) {
420
+ level = 'info';
421
+ }
422
+
423
+ logger[level](`[Plugin:${pluginId}] ${message}`);
424
+ this.emit('plugin:log', { pluginId, level, message });
425
+ }
426
+
427
+ /**
428
+ * Get plugin configuration
429
+ * @param {string} pluginId - Plugin identifier
430
+ * @param {Object} defaults - Default configuration
431
+ */
432
+ getConfig(pluginId, defaults = {}) {
433
+ const plugins = this.settings.plugins || {};
434
+ return { ...defaults, ...plugins[pluginId] };
435
+ }
436
+
437
+ /**
438
+ * Save plugin configuration
439
+ * @param {string} pluginId - Plugin identifier
440
+ * @param {Object} config - Configuration to save
441
+ */
442
+ async saveConfig(pluginId, config) {
443
+ this.emit('plugin:config', { pluginId, config });
444
+ return true;
445
+ }
446
+
447
+ /**
448
+ * Get API information
449
+ */
450
+ getInfo() {
451
+ return {
452
+ version: this.version,
453
+ dashboardVersion: this.dashboardVersion,
454
+ extensionPoints: Array.from(this.extensions.keys()),
455
+ dataProviders: Array.from(this.providers.keys()),
456
+ hooks: Array.from(this.hooks.keys()),
457
+ };
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Base Widget class for plugin developers
463
+ */
464
+ export class BaseWidget {
465
+ constructor(options = {}) {
466
+ this.id = options.id || `widget-${Date.now()}`;
467
+ this.name = options.name || 'Unnamed Widget';
468
+ this.description = options.description || '';
469
+ this.config = options.config || {};
470
+ this.api = options.api || null;
471
+ this.screen = options.screen || null;
472
+
473
+ this.box = null;
474
+ this.data = null;
475
+ this.visible = false;
476
+ this.loaded = false;
477
+
478
+ // Priority for degradation decisions (lower = more critical)
479
+ this.priority = options.priority || this.getDefaultPriority();
480
+
481
+ // Refresh interval tracking - use config default or explicit config
482
+ this.refreshInterval = this.config.refreshInterval || this.getDefaultRefreshInterval();
483
+ this.lastUpdateTime = 0;
484
+ this.updateCount = 0;
485
+ this.skipCount = 0;
486
+
487
+ // Degradation tracking
488
+ this.isDegraded = false;
489
+ this.degradationLevel = 'none';
490
+ this.currentRefreshInterval = this.refreshInterval;
491
+ }
492
+
493
+ /**
494
+ * Get the default priority for this widget type
495
+ * @returns {number} Priority value (lower = more critical)
496
+ */
497
+ getDefaultPriority() {
498
+ // Map widget ID to builtin priority from config
499
+ const widgetId = this.id.replace(/Widget$/, '').toLowerCase();
500
+ const builtinConfig = config.WIDGETS?.BUILTIN?.[widgetId];
501
+ return builtinConfig?.priority || 100;
502
+ }
503
+
504
+ /**
505
+ * Initialize the widget
506
+ * Called when widget is first loaded
507
+ */
508
+ async init() {
509
+ this.loaded = true;
510
+ return true;
511
+ }
512
+
513
+ /**
514
+ * Create the widget UI
515
+ * Override this method to create your widget's blessed elements
516
+ */
517
+ async create() {
518
+ // Override in subclass
519
+ throw new Error('create() must be implemented');
520
+ }
521
+
522
+ /**
523
+ * Get data for the widget
524
+ * Override this method to fetch widget data
525
+ */
526
+ async getData() {
527
+ // Override in subclass
528
+ return {};
529
+ }
530
+
531
+ /**
532
+ * Render the widget
533
+ * Override this method to update the widget display
534
+ */
535
+ async render() {
536
+ // Override in subclass
537
+ }
538
+
539
+ /**
540
+ * Show the widget
541
+ */
542
+ show() {
543
+ if (this.box) {
544
+ this.box.show();
545
+ this.visible = true;
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Hide the widget
551
+ */
552
+ hide() {
553
+ if (this.box) {
554
+ this.box.hide();
555
+ this.visible = false;
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Destroy the widget
561
+ * Clean up any resources
562
+ */
563
+ async destroy() {
564
+ if (this.box) {
565
+ this.box.destroy();
566
+ this.box = null;
567
+ }
568
+ this.loaded = false;
569
+ }
570
+
571
+ /**
572
+ * Log a message
573
+ * @param {string} level - Log level
574
+ * @param {string} message - Message
575
+ */
576
+ log(level, message) {
577
+ if (this.api) {
578
+ this.api.log(this.id, level, message);
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Get widget metadata
584
+ */
585
+ getMetadata() {
586
+ return {
587
+ id: this.id,
588
+ name: this.name,
589
+ description: this.description,
590
+ loaded: this.loaded,
591
+ visible: this.visible,
592
+ };
593
+ }
594
+
595
+ /**
596
+ * Get the default refresh interval for this widget type from config
597
+ * @returns {number|null} Refresh interval in milliseconds, or null to use global
598
+ */
599
+ getDefaultRefreshInterval() {
600
+ // Map widget ID to config interval
601
+ const intervalMap = {
602
+ 'cpu': config.WIDGET_REFRESH_INTERVALS?.CPU,
603
+ 'memory': config.WIDGET_REFRESH_INTERVALS?.MEMORY,
604
+ 'gpu': config.WIDGET_REFRESH_INTERVALS?.GPU,
605
+ 'network': config.WIDGET_REFRESH_INTERVALS?.NETWORK,
606
+ 'disk': config.WIDGET_REFRESH_INTERVALS?.DISK,
607
+ 'system': config.WIDGET_REFRESH_INTERVALS?.SYSTEM,
608
+ 'uptime': config.WIDGET_REFRESH_INTERVALS?.UPTIME,
609
+ 'dataHealth': config.WIDGET_REFRESH_INTERVALS?.DATA_HEALTH,
610
+ };
611
+
612
+ // Try to match by ID (or ID without 'Widget' suffix)
613
+ const widgetId = this.id.replace(/Widget$/, '').toLowerCase();
614
+ return intervalMap[widgetId] || intervalMap[this.id] || config.WIDGET_REFRESH_INTERVALS?.DEFAULT || null;
615
+ }
616
+
617
+ /**
618
+ * Check if the widget should update based on refresh interval
619
+ * @param {number} currentTime - Current timestamp (optional, defaults to Date.now())
620
+ * @returns {boolean} True if widget should update
621
+ */
622
+ shouldUpdate(currentTime = Date.now()) {
623
+ // If no custom interval set, always allow update (use global interval)
624
+ if (!this.refreshInterval) {
625
+ return true;
626
+ }
627
+
628
+ // Check if enough time has passed since last update
629
+ const timeSinceLastUpdate = currentTime - this.lastUpdateTime;
630
+ return timeSinceLastUpdate >= this.refreshInterval;
631
+ }
632
+
633
+ /**
634
+ * Check if widget should update under current degradation level
635
+ * @param {string} degradationLevel - Current degradation level ('none', 'warning', 'critical')
636
+ * @param {number} currentTime - Current timestamp
637
+ * @returns {object} Result with { shouldUpdate: boolean, reason: string }
638
+ */
639
+ shouldUpdateUnderDegradation(degradationLevel, currentTime = Date.now()) {
640
+ // If critical widget, always try to update
641
+ if (config.WIDGET_DEGRADATION?.CRITICAL_WIDGETS?.includes(this.id)) {
642
+ return { shouldUpdate: true, reason: 'critical_widget' };
643
+ }
644
+
645
+ // Under critical degradation, skip non-critical widgets
646
+ if (degradationLevel === 'critical') {
647
+ const criticalThreshold = config.WIDGET_DEGRADATION?.CRITICAL?.PRIORITY_THRESHOLD || 30;
648
+ if (this.priority > criticalThreshold) {
649
+ this.skipCount++;
650
+ return { shouldUpdate: false, reason: 'degradation_critical_skip' };
651
+ }
652
+
653
+ // Extend interval under critical degradation
654
+ const multiplier = config.WIDGET_DEGRADATION?.CRITICAL?.EXTEND_INTERVAL_MULTIPLIER || 2.0;
655
+ const adjustedInterval = (this.refreshInterval || 2000) * multiplier;
656
+ const timeSinceLastUpdate = currentTime - this.lastUpdateTime;
657
+
658
+ if (timeSinceLastUpdate < adjustedInterval) {
659
+ return { shouldUpdate: false, reason: 'degradation_extended_interval' };
660
+ }
661
+ }
662
+
663
+ // Under warning degradation, extend intervals
664
+ if (degradationLevel === 'warning') {
665
+ const multiplier = config.WIDGET_DEGRADATION?.WARNING?.EXTEND_INTERVAL_MULTIPLIER || 1.5;
666
+ const adjustedInterval = (this.refreshInterval || 2000) * multiplier;
667
+ const timeSinceLastUpdate = currentTime - this.lastUpdateTime;
668
+
669
+ if (timeSinceLastUpdate < adjustedInterval) {
670
+ return { shouldUpdate: false, reason: 'degradation_extended_interval' };
671
+ }
672
+ }
673
+
674
+ // Standard interval check
675
+ if (!this.shouldUpdate(currentTime)) {
676
+ return { shouldUpdate: false, reason: 'interval_not_elapsed' };
677
+ }
678
+
679
+ return { shouldUpdate: true, reason: 'ok' };
680
+ }
681
+
682
+ /**
683
+ * Update the refresh interval
684
+ * @param {number} interval - New interval in milliseconds
685
+ */
686
+ updateRefreshInterval(interval) {
687
+ // Validate interval
688
+ const minInterval = config.WIDGET_REFRESH_VALIDATION?.MIN_INTERVAL || 500;
689
+ const maxInterval = config.WIDGET_REFRESH_VALIDATION?.MAX_INTERVAL || 60000;
690
+
691
+ if (interval !== null && (interval < minInterval || interval > maxInterval)) {
692
+ throw new Error(`Invalid refresh interval: ${interval}. Must be between ${minInterval} and ${maxInterval}ms`);
693
+ }
694
+
695
+ this.refreshInterval = interval;
696
+ this.currentRefreshInterval = interval;
697
+
698
+ this.log('debug', `Refresh interval updated to ${interval}ms`);
699
+ }
700
+
701
+ /**
702
+ * Record that an update occurred
703
+ * @param {number} timestamp - Update timestamp (optional, defaults to Date.now())
704
+ */
705
+ recordUpdate(timestamp = Date.now()) {
706
+ this.lastUpdateTime = timestamp;
707
+ this.updateCount++;
708
+ }
709
+
710
+ /**
711
+ * Set the degradation level for this widget
712
+ * @param {string} level - Degradation level ('none', 'warning', 'critical')
713
+ */
714
+ setDegradationLevel(level) {
715
+ this.degradationLevel = level;
716
+ this.isDegraded = level !== 'none';
717
+
718
+ // Adjust current refresh interval based on degradation
719
+ if (level === 'critical') {
720
+ const multiplier = config.WIDGET_DEGRADATION?.CRITICAL?.EXTEND_INTERVAL_MULTIPLIER || 2.0;
721
+ this.currentRefreshInterval = (this.refreshInterval || 2000) * multiplier;
722
+ } else if (level === 'warning') {
723
+ const multiplier = config.WIDGET_DEGRADATION?.WARNING?.EXTEND_INTERVAL_MULTIPLIER || 1.5;
724
+ this.currentRefreshInterval = (this.refreshInterval || 2000) * multiplier;
725
+ } else {
726
+ this.currentRefreshInterval = this.refreshInterval;
727
+ }
728
+ }
729
+
730
+ /**
731
+ * Get widget refresh statistics
732
+ * @returns {object} Refresh statistics
733
+ */
734
+ getRefreshStats() {
735
+ return {
736
+ refreshInterval: this.refreshInterval,
737
+ currentRefreshInterval: this.currentRefreshInterval,
738
+ lastUpdateTime: this.lastUpdateTime,
739
+ updateCount: this.updateCount,
740
+ skippedUpdates: this.skipCount,
741
+ degradationLevel: this.degradationLevel,
742
+ priority: this.priority,
743
+ };
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Plugin Manifest validation
749
+ * @param {Object} manifest - Plugin manifest
750
+ */
751
+ export function validateManifest(manifest) {
752
+ const required = ['name', 'version', 'entryPoint'];
753
+ const missing = required.filter(key => !manifest[key]);
754
+
755
+ if (missing.length > 0) {
756
+ throw new Error(`Missing required fields in plugin manifest: ${missing.join(', ')}`);
757
+ }
758
+
759
+ // Validate version format (semver)
760
+ const semver = /^\d+\.\d+\.\d+/;
761
+ if (!semver.test(manifest.version)) {
762
+ throw new Error('Plugin version must follow semver format (e.g., 1.0.0)');
763
+ }
764
+
765
+ return true;
766
+ }
767
+
768
+ /**
769
+ * Create a widget plugin
770
+ * @param {Object} definition - Widget definition
771
+ */
772
+ export function createWidgetPlugin(definition) {
773
+ const {
774
+ id,
775
+ name,
776
+ description,
777
+ version = '1.0.0',
778
+ author,
779
+ category = 'custom',
780
+ WidgetClass,
781
+ config = {},
782
+ } = definition;
783
+
784
+ if (!WidgetClass) {
785
+ throw new Error('WidgetClass is required');
786
+ }
787
+
788
+ return {
789
+ id,
790
+ name,
791
+ description,
792
+ version,
793
+ author,
794
+ category,
795
+ type: 'widget',
796
+ config,
797
+
798
+ // Factory function
799
+ createWidget(options = {}) {
800
+ return new WidgetClass({
801
+ id,
802
+ name,
803
+ description,
804
+ config: { ...config, ...options.config },
805
+ ...options,
806
+ });
807
+ },
808
+
809
+ // Manifest for plugin system
810
+ toManifest() {
811
+ return {
812
+ id,
813
+ name,
814
+ description,
815
+ version,
816
+ author,
817
+ category,
818
+ type: 'widget',
819
+ lazyLoad: true,
820
+ };
821
+ },
822
+ };
823
+ }
824
+
825
+ // Export singleton
826
+ let defaultAPI = null;
827
+
828
+ export function getPluginAPI(options) {
829
+ if (!defaultAPI) {
830
+ defaultAPI = new PluginAPI(options);
831
+ }
832
+ return defaultAPI;
833
+ }
834
+
835
+ // Export RateLimiter for plugin use
836
+ export { RateLimiter };
837
+
838
+ export default {
839
+ PluginAPI,
840
+ BaseWidget,
841
+ validateManifest,
842
+ createWidgetPlugin,
843
+ PLUGIN_API_VERSION,
844
+ RateLimiter,
845
+ };