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,1057 @@
1
+ /**
2
+ * Built-in System Widgets for Claw Dashboard
3
+ * Each widget module can be lazy loaded on demand
4
+ */
5
+
6
+ import blessed from 'blessed';
7
+ import contrib from 'blessed-contrib';
8
+ import { BaseWidget } from './plugin-api.js';
9
+
10
+ /**
11
+ * CPU Widget - Displays CPU usage with gauge and sparkline
12
+ */
13
+ export class CpuWidget extends BaseWidget {
14
+ constructor(options = {}) {
15
+ super(options);
16
+ this.name = 'CPU';
17
+ this.description = 'CPU usage and history';
18
+ this.history = [];
19
+ this.maxHistory = 60;
20
+ }
21
+
22
+ async create(screen, theme = {}) {
23
+ const C = theme.colors || {};
24
+
25
+ this.box = blessed.box({
26
+ parent: screen,
27
+ height: 5,
28
+ border: { type: 'line' },
29
+ label: ' CPU ',
30
+ style: { border: { fg: C.cyan || 'cyan' } },
31
+ });
32
+
33
+ this.valueText = blessed.text({
34
+ parent: this.box,
35
+ top: 0,
36
+ left: 'center',
37
+ content: '0%',
38
+ style: { fg: C.brightGreen || 'bright-green', bold: true },
39
+ });
40
+
41
+ this.detailText = blessed.text({
42
+ parent: this.box,
43
+ top: 1,
44
+ left: 'center',
45
+ content: '',
46
+ style: { fg: C.gray || 'gray' },
47
+ });
48
+
49
+ return this;
50
+ }
51
+
52
+ async getData(dataProvider) {
53
+ if (dataProvider) {
54
+ return dataProvider('cpu');
55
+ }
56
+ return null;
57
+ }
58
+
59
+ update(data) {
60
+ if (!data || !this.box) return;
61
+
62
+ const percent = data.avg || 0;
63
+ const cores = data.cores || 1;
64
+
65
+ // Update history
66
+ this.history.push(percent);
67
+ if (this.history.length > this.maxHistory) {
68
+ this.history.shift();
69
+ }
70
+
71
+ // Update display
72
+ const color = percent > 90 ? 'red' : percent > 70 ? 'yellow' : 'green';
73
+ const gaugeWidth = 15;
74
+ const filled = Math.round((percent / 100) * gaugeWidth);
75
+ const gauge = '█'.repeat(filled) + '░'.repeat(gaugeWidth - filled);
76
+
77
+ this.valueText.setContent(`{${color}-fg}${percent}%{/${color}-fg} ${gauge}`);
78
+ this.detailText.setContent(`${cores} cores`);
79
+ }
80
+
81
+ render(data) {
82
+ this.update(data);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Memory Widget - Displays memory usage with gauge and sparkline
88
+ */
89
+ export class MemoryWidget extends BaseWidget {
90
+ constructor(options = {}) {
91
+ super(options);
92
+ this.name = 'Memory';
93
+ this.description = 'Memory usage and history';
94
+ this.history = [];
95
+ this.maxHistory = 60;
96
+ }
97
+
98
+ async create(screen, theme = {}) {
99
+ const C = theme.colors || {};
100
+
101
+ this.box = blessed.box({
102
+ parent: screen,
103
+ height: 5,
104
+ border: { type: 'line' },
105
+ label: ' MEMORY ',
106
+ style: { border: { fg: C.magenta || 'magenta' } },
107
+ });
108
+
109
+ this.valueText = blessed.text({
110
+ parent: this.box,
111
+ top: 0,
112
+ left: 'center',
113
+ content: '0%',
114
+ style: { fg: C.brightMagenta || 'bright-magenta', bold: true },
115
+ });
116
+
117
+ this.detailText = blessed.text({
118
+ parent: this.box,
119
+ top: 1,
120
+ left: 'center',
121
+ content: '',
122
+ style: { fg: C.gray || 'gray' },
123
+ });
124
+
125
+ return this;
126
+ }
127
+
128
+ async getData(dataProvider) {
129
+ if (dataProvider) {
130
+ return dataProvider('memory');
131
+ }
132
+ return null;
133
+ }
134
+
135
+ update(data) {
136
+ if (!data || !this.box) return;
137
+
138
+ const percent = data.percent || 0;
139
+ const used = data.used || 0;
140
+ const total = data.total || 0;
141
+
142
+ // Update history
143
+ this.history.push(percent);
144
+ if (this.history.length > this.maxHistory) {
145
+ this.history.shift();
146
+ }
147
+
148
+ // Update display
149
+ const color = percent > 90 ? 'red' : percent > 75 ? 'yellow' : 'green';
150
+ const gaugeWidth = 15;
151
+ const filled = Math.round((percent / 100) * gaugeWidth);
152
+ const gauge = '█'.repeat(filled) + '░'.repeat(gaugeWidth - filled);
153
+
154
+ this.valueText.setContent(`{${color}-fg}${percent}%{/${color}-fg} ${gauge}`);
155
+ this.detailText.setContent(`${used}/${total} GB`);
156
+ }
157
+
158
+ render(data) {
159
+ this.update(data);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * GPU Widget - Displays GPU information
165
+ */
166
+ export class GpuWidget extends BaseWidget {
167
+ constructor(options = {}) {
168
+ super(options);
169
+ this.name = 'GPU';
170
+ this.description = 'GPU usage and temperature';
171
+ }
172
+
173
+ async create(screen, theme = {}) {
174
+ const C = theme.colors || {};
175
+
176
+ this.box = blessed.box({
177
+ parent: screen,
178
+ height: 5,
179
+ border: { type: 'line' },
180
+ label: ' GPU ',
181
+ style: { border: { fg: C.yellow || 'yellow' } },
182
+ });
183
+
184
+ this.valueText = blessed.text({
185
+ parent: this.box,
186
+ top: 0,
187
+ left: 'center',
188
+ content: 'Detecting...',
189
+ style: { fg: C.brightYellow || 'bright-yellow', bold: true },
190
+ });
191
+
192
+ this.detailText = blessed.text({
193
+ parent: this.box,
194
+ top: 1,
195
+ left: 'center',
196
+ content: '',
197
+ style: { fg: C.gray || 'gray' },
198
+ });
199
+
200
+ return this;
201
+ }
202
+
203
+ async getData(dataProvider) {
204
+ if (dataProvider) {
205
+ return dataProvider('gpu');
206
+ }
207
+ return null;
208
+ }
209
+
210
+ update(data) {
211
+ if (!this.box) return;
212
+
213
+ if (!data) {
214
+ this.valueText.setContent('Not detected');
215
+ this.detailText.setContent('');
216
+ return;
217
+ }
218
+
219
+ const utilization = data.utilization;
220
+ const temp = data.temperature;
221
+ const short = data.short || 'GPU';
222
+
223
+ let value = short;
224
+ if (utilization !== null) {
225
+ value += ` ${utilization}%`;
226
+ }
227
+ if (temp !== null) {
228
+ value += ` ${temp}°C`;
229
+ }
230
+
231
+ this.valueText.setContent(value);
232
+ this.detailText.setContent(data.memoryUsed && data.memoryTotal
233
+ ? `${data.memoryUsed}/${data.memoryTotal} GB`
234
+ : '');
235
+ }
236
+
237
+ render(data) {
238
+ this.update(data);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Network Widget - Displays network activity
244
+ */
245
+ export class NetworkWidget extends BaseWidget {
246
+ constructor(options = {}) {
247
+ super(options);
248
+ this.name = 'Network';
249
+ this.description = 'Network activity';
250
+ this.history = [];
251
+ this.maxHistory = 30;
252
+ }
253
+
254
+ async create(screen, theme = {}) {
255
+ const C = theme.colors || {};
256
+
257
+ this.box = blessed.box({
258
+ parent: screen,
259
+ height: 5,
260
+ border: { type: 'line' },
261
+ label: ' NETWORK ',
262
+ style: { border: { fg: C.brightCyan || 'bright-cyan' } },
263
+ });
264
+
265
+ this.valueText = blessed.text({
266
+ parent: this.box,
267
+ top: 0,
268
+ left: 'center',
269
+ content: 'Loading...',
270
+ style: { fg: C.brightCyan || 'bright-cyan', bold: true },
271
+ });
272
+
273
+ this.detailText = blessed.text({
274
+ parent: this.box,
275
+ top: 1,
276
+ left: 'center',
277
+ content: '',
278
+ style: { fg: C.gray || 'gray' },
279
+ });
280
+
281
+ this.sparkline = contrib.sparkline({
282
+ parent: this.box,
283
+ top: 2,
284
+ left: 'center',
285
+ width: 20,
286
+ height: 1,
287
+ style: { fg: C.cyan || 'cyan' },
288
+ });
289
+
290
+ return this;
291
+ }
292
+
293
+ async getData(dataProvider) {
294
+ if (dataProvider) {
295
+ return dataProvider('network');
296
+ }
297
+ return null;
298
+ }
299
+
300
+ formatBytes(bytes) {
301
+ if (bytes === 0) return '0 B';
302
+ const k = 1024;
303
+ const sizes = ['B', 'KB', 'MB', 'GB'];
304
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
305
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
306
+ }
307
+
308
+ update(data) {
309
+ if (!data || !this.box) return;
310
+
311
+ const iface = data.interface || 'unknown';
312
+ const rx = data.rxSec || 0;
313
+ const tx = data.txSec || 0;
314
+ const total = rx + tx;
315
+
316
+ // Update history
317
+ this.history.push(total);
318
+ if (this.history.length > this.maxHistory) {
319
+ this.history.shift();
320
+ }
321
+
322
+ this.valueText.setContent(iface);
323
+ this.detailText.setContent(`↓${this.formatBytes(rx)} ↑${this.formatBytes(tx)}`);
324
+
325
+ if (this.sparkline && this.history.length > 1) {
326
+ this.sparkline.setData([this.history]);
327
+ }
328
+ }
329
+
330
+ render(data) {
331
+ this.update(data);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Disk Widget - Displays disk usage
337
+ */
338
+ export class DiskWidget extends BaseWidget {
339
+ constructor(options = {}) {
340
+ super(options);
341
+ this.name = 'Disk';
342
+ this.description = 'Disk usage';
343
+ }
344
+
345
+ async create(screen, theme = {}) {
346
+ const C = theme.colors || {};
347
+
348
+ this.box = blessed.box({
349
+ parent: screen,
350
+ height: 5,
351
+ border: { type: 'line' },
352
+ label: ' DISK ',
353
+ style: { border: { fg: C.green || 'green' } },
354
+ });
355
+
356
+ this.valueText = blessed.text({
357
+ parent: this.box,
358
+ top: 0,
359
+ left: 'center',
360
+ content: '0%',
361
+ style: { fg: C.brightGreen || 'bright-green', bold: true },
362
+ });
363
+
364
+ this.detailText = blessed.text({
365
+ parent: this.box,
366
+ top: 1,
367
+ left: 'center',
368
+ content: '',
369
+ style: { fg: C.gray || 'gray' },
370
+ });
371
+
372
+ return this;
373
+ }
374
+
375
+ async getData(dataProvider) {
376
+ if (dataProvider) {
377
+ return dataProvider('disk');
378
+ }
379
+ return null;
380
+ }
381
+
382
+ update(data) {
383
+ if (!data || !this.box) return;
384
+
385
+ const percent = data.percent || 0;
386
+ const used = data.used || 0;
387
+ const size = data.size || 0;
388
+
389
+ const color = percent > 90 ? 'red' : percent > 80 ? 'yellow' : 'green';
390
+ const gaugeWidth = 15;
391
+ const filled = Math.round((percent / 100) * gaugeWidth);
392
+ const gauge = '█'.repeat(filled) + '░'.repeat(gaugeWidth - filled);
393
+
394
+ this.valueText.setContent(`{${color}-fg}${percent}%{/${color}-fg} ${gauge}`);
395
+ this.detailText.setContent(`${used}/${size} GB`);
396
+ }
397
+
398
+ render(data) {
399
+ this.update(data);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * System Widget - Displays system information
405
+ */
406
+ export class SystemWidget extends BaseWidget {
407
+ constructor(options = {}) {
408
+ super(options);
409
+ this.name = 'System';
410
+ this.description = 'System information';
411
+ }
412
+
413
+ async create(screen, theme = {}) {
414
+ const C = theme.colors || {};
415
+
416
+ this.box = blessed.box({
417
+ parent: screen,
418
+ height: 5,
419
+ border: { type: 'line' },
420
+ label: ' SYSTEM ',
421
+ style: { border: { fg: C.gray || 'gray' } },
422
+ });
423
+
424
+ this.line1 = blessed.text({
425
+ parent: this.box,
426
+ top: 0,
427
+ left: 'center',
428
+ content: '...',
429
+ style: { fg: C.gray || 'gray' },
430
+ });
431
+
432
+ this.line2 = blessed.text({
433
+ parent: this.box,
434
+ top: 1,
435
+ left: 'center',
436
+ content: '',
437
+ style: { fg: C.gray || 'gray' },
438
+ });
439
+
440
+ return this;
441
+ }
442
+
443
+ async getData(dataProvider) {
444
+ if (dataProvider) {
445
+ return dataProvider('system');
446
+ }
447
+ return null;
448
+ }
449
+
450
+ update(data) {
451
+ if (!data || !this.box) return;
452
+
453
+ const platform = data.platform || '';
454
+ const release = data.release || '';
455
+ const arch = data.arch || '';
456
+ const container = data.isContainer ? ' [container]' : '';
457
+
458
+ this.line1.setContent(`${platform} ${release}`);
459
+ this.line2.setContent(`${arch}${container}`);
460
+ }
461
+
462
+ render(data) {
463
+ this.update(data);
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Uptime Widget - Displays system and claw uptime
469
+ */
470
+ export class UptimeWidget extends BaseWidget {
471
+ constructor(options = {}) {
472
+ super(options);
473
+ this.name = 'Uptime';
474
+ this.description = 'System and OpenClaw uptime';
475
+ }
476
+
477
+ async create(screen, theme = {}) {
478
+ const C = theme.colors || {};
479
+
480
+ this.box = blessed.box({
481
+ parent: screen,
482
+ height: 5,
483
+ border: { type: 'line' },
484
+ label: ' UPTIME ',
485
+ style: { border: { fg: C.brightMagenta || 'bright-magenta' } },
486
+ });
487
+
488
+ this.sysText = blessed.text({
489
+ parent: this.box,
490
+ top: 0,
491
+ left: 'center',
492
+ content: 'Sys: --',
493
+ style: { fg: C.brightMagenta || 'bright-magenta', bold: true },
494
+ });
495
+
496
+ this.clawText = blessed.text({
497
+ parent: this.box,
498
+ top: 1,
499
+ left: 'center',
500
+ content: 'Claw: --',
501
+ style: { fg: C.brightMagenta || 'bright-magenta', bold: true },
502
+ });
503
+
504
+ return this;
505
+ }
506
+
507
+ async getData(dataProvider) {
508
+ if (dataProvider) {
509
+ const [sysUptime, clawUptime] = await Promise.all([
510
+ dataProvider('uptime'),
511
+ dataProvider('clawUptime'),
512
+ ]);
513
+ return { sysUptime, clawUptime };
514
+ }
515
+ return null;
516
+ }
517
+
518
+ formatUptime(seconds) {
519
+ const days = Math.floor(seconds / 86400);
520
+ const hours = Math.floor((seconds % 86400) / 3600);
521
+ const mins = Math.floor((seconds % 3600) / 60);
522
+
523
+ if (days > 0) return `${days}d ${hours}h ${mins}m`;
524
+ if (hours > 0) return `${hours}h ${mins}m`;
525
+ return `${mins}m`;
526
+ }
527
+
528
+ update(data) {
529
+ if (!data || !this.box) return;
530
+
531
+ const sysUptime = data.sysUptime || 0;
532
+ const clawUptime = data.clawUptime || 0;
533
+
534
+ this.sysText.setContent(`Sys: ${this.formatUptime(sysUptime)}`);
535
+ this.clawText.setContent(`Claw: ${this.formatUptime(clawUptime)}`);
536
+ }
537
+
538
+ render(data) {
539
+ this.update(data);
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Data Health Widget - Shows data freshness
545
+ */
546
+ export class DataHealthWidget extends BaseWidget {
547
+ constructor(options = {}) {
548
+ super(options);
549
+ this.name = 'Data Health';
550
+ this.description = 'Data freshness indicators';
551
+ this.lastUpdate = null;
552
+ }
553
+
554
+ async create(screen, theme = {}) {
555
+ const C = theme.colors || {};
556
+
557
+ this.box = blessed.box({
558
+ parent: screen,
559
+ height: 5,
560
+ border: { type: 'line' },
561
+ label: ' DATA HEALTH ',
562
+ style: { border: { fg: C.green || 'green' } },
563
+ });
564
+
565
+ this.statusText = blessed.text({
566
+ parent: this.box,
567
+ top: 0,
568
+ left: 'center',
569
+ content: 'All Fresh',
570
+ style: { fg: C.brightGreen || 'bright-green', bold: true },
571
+ });
572
+
573
+ this.detailText = blessed.text({
574
+ parent: this.box,
575
+ top: 1,
576
+ left: 'center',
577
+ content: '',
578
+ style: { fg: C.gray || 'gray' },
579
+ });
580
+
581
+ return this;
582
+ }
583
+
584
+ async getData(dataProvider) {
585
+ // This widget tracks its own update time
586
+ return { timestamp: Date.now() };
587
+ }
588
+
589
+ update(data) {
590
+ if (!this.box) return;
591
+
592
+ const now = Date.now();
593
+ if (data?.timestamp) {
594
+ this.lastUpdate = data.timestamp;
595
+ }
596
+
597
+ if (!this.lastUpdate) {
598
+ this.statusText.setContent('Waiting...');
599
+ return;
600
+ }
601
+
602
+ const age = now - this.lastUpdate;
603
+ const ageSec = Math.floor(age / 1000);
604
+
605
+ let status = 'All Fresh';
606
+ let color = 'green';
607
+ let detail = `${ageSec}s ago`;
608
+
609
+ if (age > 30000) {
610
+ status = 'Stale';
611
+ color = 'red';
612
+ detail = `Last update: ${ageSec}s ago`;
613
+ } else if (age > 10000) {
614
+ status = 'Aging';
615
+ color = 'yellow';
616
+ detail = `${ageSec}s since last refresh`;
617
+ }
618
+
619
+ this.statusText.setContent(`{${color}-fg}${status}{/${color}-fg}`);
620
+ this.detailText.setContent(detail);
621
+ }
622
+
623
+ render(data) {
624
+ this.update(data);
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Settings Widget - Interactive settings panel for user preferences
630
+ * Allows users to modify theme, refresh rate, and other settings
631
+ */
632
+ export class SettingsWidget extends BaseWidget {
633
+ constructor(options = {}) {
634
+ super(options);
635
+ this.name = 'Settings';
636
+ this.description = 'User preferences configuration';
637
+ this.settings = options.settings || {};
638
+ this.onSettingsChange = options.onSettingsChange || null;
639
+ this.onSave = options.onSave || null;
640
+ this.currentIndex = 0;
641
+ this.isEditing = false;
642
+ }
643
+
644
+ async create(screen, theme = {}) {
645
+ const C = theme.colors || {};
646
+
647
+ this.box = blessed.box({
648
+ parent: screen,
649
+ height: 12,
650
+ border: { type: 'line' },
651
+ label: ' SETTINGS ',
652
+ style: { border: { fg: C.cyan || 'cyan' } },
653
+ });
654
+
655
+ this.instructionsText = blessed.text({
656
+ parent: this.box,
657
+ top: 0,
658
+ left: 0,
659
+ right: 0,
660
+ content: ' {cyan-fg}j/k{/cyan-fg} navigate {cyan-fg}enter{/cyan-fg} edit {cyan-fg}s{/cyan-fg} save {cyan-fg}q{/cyan-fg} close',
661
+ style: { fg: C.gray || 'gray' },
662
+ });
663
+
664
+ this.settingsList = blessed.list({
665
+ parent: this.box,
666
+ top: 1,
667
+ left: 0,
668
+ right: 0,
669
+ bottom: 0,
670
+ keys: true,
671
+ interactive: false,
672
+ style: {
673
+ item: { fg: C.white || 'white' },
674
+ selected: { fg: C.black || 'black', bg: C.cyan || 'cyan', bold: true },
675
+ focus: { fg: C.black || 'black', bg: C.cyan || 'cyan' },
676
+ },
677
+ });
678
+
679
+ // Set up keyboard navigation
680
+ this.setupKeys();
681
+
682
+ return this;
683
+ }
684
+
685
+ setupKeys() {
686
+ this.settingsList.key(['j', 'down'], () => {
687
+ if (!this.isEditing) {
688
+ this.currentIndex = Math.min(this.currentIndex + 1, this.getSettingsCount() - 1);
689
+ this.updateSelection();
690
+ }
691
+ });
692
+
693
+ this.settingsList.key(['k', 'up'], () => {
694
+ if (!this.isEditing) {
695
+ this.currentIndex = Math.max(this.currentIndex - 1, 0);
696
+ this.updateSelection();
697
+ }
698
+ });
699
+
700
+ this.settingsList.key(['g', 'home'], () => {
701
+ if (!this.isEditing) {
702
+ this.currentIndex = 0;
703
+ this.updateSelection();
704
+ }
705
+ });
706
+
707
+ this.settingsList.key(['G', 'end'], () => {
708
+ if (!this.isEditing) {
709
+ this.currentIndex = this.getSettingsCount() - 1;
710
+ this.updateSelection();
711
+ }
712
+ });
713
+
714
+ this.settingsList.key(['enter', 'space'], () => {
715
+ this.editCurrentSetting();
716
+ });
717
+
718
+ this.settingsList.key('s', () => {
719
+ this.saveSettings();
720
+ });
721
+
722
+ this.settingsList.key(['q', 'escape'], () => {
723
+ if (this.onClose) this.onClose();
724
+ });
725
+
726
+ // Focus the list
727
+ this.settingsList.focus();
728
+ }
729
+
730
+ getSettingsCount() {
731
+ // Theme, Refresh Rate, Log Level, Show/Hide Widgets (9), Export Format
732
+ return 13;
733
+ }
734
+
735
+ getSettingsOptions() {
736
+ return [
737
+ { key: 'theme', label: 'Theme', options: ['auto', 'default', 'dark', 'high-contrast', 'ocean'] },
738
+ { key: 'refreshInterval', label: 'Refresh Rate', options: ['1000ms', '2000ms', '5000ms', '10000ms'] },
739
+ { key: 'logLevelFilter', label: 'Log Level', options: ['all', 'error', 'warn', 'info', 'debug'] },
740
+ { key: 'showWidget1', label: 'Show CPU Widget', options: ['ON', 'OFF'] },
741
+ { key: 'showWidget2', label: 'Show Memory Widget', options: ['ON', 'OFF'] },
742
+ { key: 'showWidget3', label: 'Show GPU Widget', options: ['ON', 'OFF'] },
743
+ { key: 'showWidget4', label: 'Show Network Widget', options: ['ON', 'OFF'] },
744
+ { key: 'showWidget5', label: 'Show Disk Widget', options: ['ON', 'OFF'] },
745
+ { key: 'showWidget6', label: 'Show System Widget', options: ['ON', 'OFF'] },
746
+ { key: 'showWidget7', label: 'Show Uptime Widget', options: ['ON', 'OFF'] },
747
+ { key: 'showWidget8', label: 'Show Data Health Widget', options: ['ON', 'OFF'] },
748
+ { key: 'showWidget9', label: 'Show Gateway Widget', options: ['ON', 'OFF'] },
749
+ { key: 'exportFormat', label: 'Export Format', options: ['json', 'csv'] },
750
+ ];
751
+ }
752
+
753
+ formatSettingRow(option, index) {
754
+ const currentValue = this.settings[option.key];
755
+ let displayValue;
756
+
757
+ if (option.key === 'refreshInterval') {
758
+ displayValue = `${currentValue}ms`;
759
+ } else if (option.key.startsWith('showWidget')) {
760
+ displayValue = currentValue !== false ? 'ON' : 'OFF';
761
+ } else {
762
+ displayValue = currentValue || 'auto';
763
+ }
764
+
765
+ const label = option.label.padEnd(25, ' ');
766
+ const isSelected = index === this.currentIndex;
767
+ const prefix = isSelected ? '> ' : ' ';
768
+
769
+ return `${prefix}${label} ${displayValue}`;
770
+ }
771
+
772
+ updateDisplay() {
773
+ if (!this.settingsList) return;
774
+
775
+ const options = this.getSettingsOptions();
776
+ const items = options.map((opt, idx) => this.formatSettingRow(opt, idx));
777
+ this.settingsList.setItems(items);
778
+ this.updateSelection();
779
+ }
780
+
781
+ updateSelection() {
782
+ if (this.settingsList) {
783
+ this.settingsList.select(this.currentIndex);
784
+ this.box.screen.render();
785
+ }
786
+ }
787
+
788
+ editCurrentSetting() {
789
+ const options = this.getSettingsOptions();
790
+ const option = options[this.currentIndex];
791
+ if (!option) return;
792
+
793
+ const currentValue = this.settings[option.key];
794
+
795
+ if (option.key === 'refreshInterval') {
796
+ // Cycle through refresh intervals
797
+ const intervals = [1000, 2000, 5000, 10000];
798
+ const currentIdx = intervals.indexOf(currentValue);
799
+ const nextIdx = (currentIdx + 1) % intervals.length;
800
+ this.settings[option.key] = intervals[nextIdx];
801
+ } else if (option.key.startsWith('showWidget')) {
802
+ // Toggle boolean
803
+ this.settings[option.key] = currentValue === false;
804
+ } else if (option.options) {
805
+ // Cycle through options
806
+ const currentIdx = option.options.indexOf(currentValue);
807
+ const nextIdx = (currentIdx + 1) % option.options.length;
808
+ this.settings[option.key] = option.options[nextIdx];
809
+ }
810
+
811
+ this.updateDisplay();
812
+
813
+ // Notify of immediate change
814
+ if (this.onSettingsChange) {
815
+ this.onSettingsChange({ [option.key]: this.settings[option.key] });
816
+ }
817
+ }
818
+
819
+ saveSettings() {
820
+ if (this.onSave) {
821
+ this.onSave(this.settings);
822
+ }
823
+ // Show brief feedback
824
+ this.instructionsText.setContent(' {green-fg}Settings saved!{/green-fg}');
825
+ setTimeout(() => {
826
+ this.instructionsText.setContent(' {cyan-fg}j/k{/cyan-fg} navigate {cyan-fg}enter{/cyan-fg} edit {cyan-fg}s{/cyan-fg} save {cyan-fg}q{/cyan-fg} close');
827
+ this.box.screen.render();
828
+ }, 1000);
829
+ }
830
+
831
+ async getData(dataProvider) {
832
+ // Settings widget shows current settings
833
+ return { settings: this.settings };
834
+ }
835
+
836
+ update(data) {
837
+ if (data?.settings) {
838
+ this.settings = data.settings;
839
+ }
840
+ this.updateDisplay();
841
+ }
842
+
843
+ render(data) {
844
+ this.update(data);
845
+ }
846
+
847
+ focus() {
848
+ if (this.settingsList) {
849
+ this.settingsList.focus();
850
+ }
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Gateway Status Widget - Shows gateway connection status with offline indicator
856
+ * Displays connected/reachable status for all configured endpoints with retry UI
857
+ */
858
+ export class GatewayStatusWidget extends BaseWidget {
859
+ constructor(options = {}) {
860
+ super(options);
861
+ this.name = 'Gateway Status';
862
+ this.description = 'Gateway connection status and health';
863
+ this.onRetry = options.onRetry || null;
864
+ this.selectedEndpoint = 0;
865
+ }
866
+
867
+ async create(screen, theme = {}) {
868
+ const C = theme.colors || {};
869
+
870
+ this.box = blessed.box({
871
+ parent: screen,
872
+ height: 6,
873
+ border: { type: 'line' },
874
+ label: ' GATEWAY STATUS ',
875
+ style: { border: { fg: C.cyan || 'cyan' } },
876
+ });
877
+
878
+ this.statusText = blessed.text({
879
+ parent: this.box,
880
+ top: 0,
881
+ left: 'center',
882
+ content: 'Checking...',
883
+ style: { fg: C.brightCyan || 'bright-cyan', bold: true },
884
+ });
885
+
886
+ this.endpointsText = blessed.text({
887
+ parent: this.box,
888
+ top: 1,
889
+ left: 1,
890
+ right: 1,
891
+ content: '',
892
+ style: { fg: C.white || 'white' },
893
+ });
894
+
895
+ this.detailText = blessed.text({
896
+ parent: this.box,
897
+ top: 4,
898
+ left: 'center',
899
+ content: '',
900
+ style: { fg: C.gray || 'gray' },
901
+ });
902
+
903
+ // Setup keyboard shortcuts for this widget
904
+ this.setupKeys();
905
+
906
+ return this;
907
+ }
908
+
909
+ setupKeys() {
910
+ this.box.key(['r'], () => {
911
+ this.triggerRetry();
912
+ });
913
+
914
+ this.box.key(['j', 'down'], () => {
915
+ const endpointHealth = this.lastHealthData || [];
916
+ if (endpointHealth.length > 0) {
917
+ this.selectedEndpoint = Math.min(this.selectedEndpoint + 1, endpointHealth.length - 1);
918
+ this.updateDisplay();
919
+ }
920
+ });
921
+
922
+ this.box.key(['k', 'up'], () => {
923
+ this.selectedEndpoint = Math.max(this.selectedEndpoint - 1, 0);
924
+ this.updateDisplay();
925
+ });
926
+ }
927
+
928
+ async getData(dataProvider) {
929
+ if (dataProvider) {
930
+ return dataProvider('gatewayHealth');
931
+ }
932
+ return null;
933
+ }
934
+
935
+ triggerRetry() {
936
+ if (this.onRetry) {
937
+ const endpointHealth = this.lastHealthData || [];
938
+ const selected = endpointHealth[this.selectedEndpoint];
939
+ this.onRetry(selected?.name || null);
940
+
941
+ // Show retry feedback
942
+ this.detailText.setContent('{yellow-fg}⟳ Retrying...{/yellow-fg}');
943
+ this.box.screen.render();
944
+ }
945
+ }
946
+
947
+ update(data) {
948
+ if (!this.box) return;
949
+
950
+ this.lastHealthData = data?.endpoints || [];
951
+ this.updateDisplay();
952
+ }
953
+
954
+ updateDisplay() {
955
+ const endpointHealth = this.lastHealthData || [];
956
+
957
+ if (endpointHealth.length === 0) {
958
+ this.statusText.setContent('{red-fg}No Endpoints{/red-fg}');
959
+ this.endpointsText.setContent('No gateway endpoints configured');
960
+ this.detailText.setContent('');
961
+ return;
962
+ }
963
+
964
+ const total = endpointHealth.length;
965
+ const reachable = endpointHealth.filter(ep => ep.reachable).length;
966
+ const unreachable = total - reachable;
967
+
968
+ // Update overall status
969
+ if (unreachable === 0) {
970
+ this.statusText.setContent(`{green-fg}✓ All Connected (${reachable}/${total}){/green-fg}`);
971
+ } else if (reachable === 0) {
972
+ this.statusText.setContent(`{red-fg}✗ All Offline (${unreachable}/${total}){/red-fg}`);
973
+ } else {
974
+ this.statusText.setContent(`{yellow-fg}⚠ Partial (${reachable}/${total}){/yellow-fg}`);
975
+ }
976
+
977
+ // Build endpoint list display
978
+ const lines = [];
979
+ endpointHealth.forEach((ep, idx) => {
980
+ const isSelected = idx === this.selectedEndpoint;
981
+ const prefix = isSelected ? '> ' : ' ';
982
+ const statusIcon = ep.reachable ? '{green-fg}●{/green-fg}' : '{red-fg}●{/red-fg}';
983
+ const latency = ep.latency ? ` ${ep.latency}ms` : '';
984
+ const failInfo = ep.failCount > 0 ? ` (${ep.failCount} fails)` : '';
985
+ const line = `${prefix}${statusIcon} ${ep.name}${latency}${failInfo}`;
986
+ lines.push(line);
987
+ });
988
+
989
+ // Show up to 2 endpoints (fits in widget height)
990
+ this.endpointsText.setContent(lines.slice(0, 3).join('\n'));
991
+
992
+ // Show instructions
993
+ const selected = endpointHealth[this.selectedEndpoint];
994
+ if (selected && !selected.reachable) {
995
+ this.detailText.setContent('{yellow-fg}Press [r] to retry{/yellow-fg}');
996
+ } else {
997
+ this.detailText.setContent('{gray-fg}j/k navigate, [r] retry{/gray-fg}');
998
+ }
999
+ }
1000
+
1001
+ render(data) {
1002
+ this.update(data);
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * Widget registry - maps widget types to classes
1008
+ */
1009
+ export const WIDGET_REGISTRY = {
1010
+ cpu: CpuWidget,
1011
+ memory: MemoryWidget,
1012
+ gpu: GpuWidget,
1013
+ network: NetworkWidget,
1014
+ disk: DiskWidget,
1015
+ system: SystemWidget,
1016
+ uptime: UptimeWidget,
1017
+ dataHealth: DataHealthWidget,
1018
+ settings: SettingsWidget,
1019
+ gatewayStatus: GatewayStatusWidget,
1020
+ };
1021
+
1022
+ /**
1023
+ * Create a widget instance
1024
+ * @param {string} type - Widget type
1025
+ * @param {Object} options - Widget options
1026
+ */
1027
+ export function createWidget(type, options = {}) {
1028
+ const WidgetClass = WIDGET_REGISTRY[type];
1029
+ if (!WidgetClass) {
1030
+ throw new Error(`Unknown widget type: ${type}`);
1031
+ }
1032
+ // Pass the widget type as the ID for proper config matching
1033
+ return new WidgetClass({ ...options, id: options.id || type });
1034
+ }
1035
+
1036
+ /**
1037
+ * Get all available widget types
1038
+ */
1039
+ export function getWidgetTypes() {
1040
+ return Object.keys(WIDGET_REGISTRY);
1041
+ }
1042
+
1043
+ export default {
1044
+ CpuWidget,
1045
+ MemoryWidget,
1046
+ GpuWidget,
1047
+ NetworkWidget,
1048
+ DiskWidget,
1049
+ SystemWidget,
1050
+ UptimeWidget,
1051
+ DataHealthWidget,
1052
+ SettingsWidget,
1053
+ GatewayStatusWidget,
1054
+ createWidget,
1055
+ getWidgetTypes,
1056
+ WIDGET_REGISTRY,
1057
+ };