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,773 @@
1
+ /**
2
+ * Widget Error Boundary Module
3
+ * Provides visual error boundaries for widgets with retry functionality
4
+ * Wraps widgets to catch errors and display user-friendly error UI
5
+ */
6
+
7
+ import blessed from 'blessed';
8
+ import logger from '../logger.js';
9
+ import {
10
+ WidgetErrorIsolator,
11
+ WidgetHealthStatus,
12
+ WidgetErrorType,
13
+ } from './widget-error-isolation.js';
14
+
15
+ /**
16
+ * Error state visual styles
17
+ */
18
+ export const ErrorStyles = {
19
+ CONTAINER: {
20
+ border: { type: 'line' },
21
+ style: {
22
+ border: { fg: 'red' },
23
+ bg: 'black',
24
+ },
25
+ },
26
+ TITLE: {
27
+ fg: 'red',
28
+ bold: true,
29
+ },
30
+ MESSAGE: {
31
+ fg: 'white',
32
+ bg: 'black',
33
+ },
34
+ ERROR_DETAIL: {
35
+ fg: 'gray',
36
+ bg: 'black',
37
+ },
38
+ RETRY_BUTTON: {
39
+ fg: 'black',
40
+ bg: 'green',
41
+ bold: true,
42
+ },
43
+ RETRY_BUTTON_FOCUSED: {
44
+ fg: 'black',
45
+ bg: 'bright-green',
46
+ bold: true,
47
+ },
48
+ DISMISS_BUTTON: {
49
+ fg: 'white',
50
+ bg: 'gray',
51
+ },
52
+ ICON: {
53
+ fg: 'red',
54
+ bg: 'black',
55
+ },
56
+ };
57
+
58
+ /**
59
+ * Widget error boundary class
60
+ * Wraps a widget with error handling and visual error UI
61
+ */
62
+ export class WidgetErrorBoundary {
63
+ constructor(widget, options = {}) {
64
+ this.widget = widget;
65
+ this.options = {
66
+ maxRetries: options.maxRetries ?? 3,
67
+ retryDelay: options.retryDelay ?? 5000,
68
+ showErrorDetails: options.showErrorDetails ?? true,
69
+ allowDismiss: options.allowDismiss ?? true,
70
+ errorTitle: options.errorTitle ?? 'Widget Error',
71
+ onRetry: options.onRetry ?? null,
72
+ onDismiss: options.onDismiss ?? null,
73
+ onError: options.onError ?? null,
74
+ theme: options.theme ?? {},
75
+ };
76
+
77
+ this.errorState = {
78
+ hasError: false,
79
+ error: null,
80
+ retryCount: 0,
81
+ lastError: null,
82
+ isRecovering: false,
83
+ };
84
+
85
+ this.isolator = new WidgetErrorIsolator({
86
+ maxConsecutiveErrors: this.options.maxRetries,
87
+ recoveryDelayMs: this.options.retryDelay,
88
+ autoRecover: true,
89
+ });
90
+
91
+ this.errorContainer = null;
92
+ this.retryButton = null;
93
+ this.dismissButton = null;
94
+ this.errorText = null;
95
+ this.originalBox = null;
96
+ this.parentScreen = null;
97
+
98
+ // Bind methods
99
+ this.handleRetry = this.handleRetry.bind(this);
100
+ this.handleDismiss = this.handleDismiss.bind(this);
101
+ this.handleKeypress = this.handleKeypress.bind(this);
102
+ }
103
+
104
+ /**
105
+ * Wrap the widget's create method with error boundary
106
+ * @param {Object} screen - Blessed screen
107
+ * @param {Object} theme - Theme configuration
108
+ */
109
+ async create(screen, theme = {}) {
110
+ this.parentScreen = screen;
111
+ this.options.theme = { ...this.options.theme, ...theme };
112
+
113
+ try {
114
+ // Try to create the widget normally
115
+ const result = await this.isolator.wrapCreate(
116
+ this.widget.id || 'widget',
117
+ () => this.widget.create(screen, theme)
118
+ );
119
+
120
+ if (result === null) {
121
+ // Widget creation failed, show error boundary
122
+ this.showErrorBoundary(this.isolator.getHealth(this.widget.id)?.lastError?.message || 'Widget creation failed');
123
+ return this;
124
+ }
125
+
126
+ // Store reference to the widget's box for hiding/showing
127
+ this.originalBox = this.widget.box;
128
+
129
+ return this;
130
+ } catch (error) {
131
+ this.handleError(error, WidgetErrorType.CREATE_ERROR);
132
+ return this;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Show the error boundary UI
138
+ * @param {string} message - Error message to display
139
+ * @param {Error} originalError - Original error object
140
+ * @private
141
+ */
142
+ showErrorBoundary(message, originalError = null) {
143
+ if (!this.parentScreen) {
144
+ logger.error('Cannot show error boundary without parent screen');
145
+ return;
146
+ }
147
+
148
+ const C = this.options.theme.colors || {};
149
+ const styles = this.getErrorStyles(C);
150
+
151
+ // Hide the original widget box if it exists
152
+ if (this.originalBox && !this.originalBox.destroyed) {
153
+ this.originalBox.hide();
154
+ }
155
+
156
+ // Create error container
157
+ this.errorContainer = blessed.box({
158
+ parent: this.parentScreen,
159
+ ...styles.container,
160
+ label: ` ${this.options.errorTitle} `,
161
+ tags: true,
162
+ });
163
+
164
+ // Try to position error container where widget would be
165
+ if (this.originalBox) {
166
+ this.errorContainer.top = this.originalBox.top;
167
+ this.errorContainer.left = this.originalBox.left;
168
+ this.errorContainer.width = this.originalBox.width;
169
+ this.errorContainer.height = this.originalBox.height;
170
+ }
171
+
172
+ // Error icon
173
+ blessed.text({
174
+ parent: this.errorContainer,
175
+ top: 1,
176
+ left: 'center',
177
+ content: '{red-fg}✖{/red-fg}',
178
+ tags: true,
179
+ style: styles.icon,
180
+ });
181
+
182
+ // Error title
183
+ blessed.text({
184
+ parent: this.errorContainer,
185
+ top: 2,
186
+ left: 'center',
187
+ content: '{bold}Widget Failed{/bold}',
188
+ tags: true,
189
+ style: styles.title,
190
+ });
191
+
192
+ // Error message
193
+ const shortMessage = message.length > 40 ? message.substring(0, 37) + '...' : message;
194
+ this.errorText = blessed.text({
195
+ parent: this.errorContainer,
196
+ top: 3,
197
+ left: 'center',
198
+ content: shortMessage,
199
+ tags: true,
200
+ style: styles.message,
201
+ });
202
+
203
+ // Show error details if enabled
204
+ let currentTop = 4;
205
+ if (this.options.showErrorDetails && originalError?.stack) {
206
+ const stackLines = originalError.stack.split('\n').slice(0, 3);
207
+ blessed.text({
208
+ parent: this.errorContainer,
209
+ top: currentTop++,
210
+ left: 'center',
211
+ content: stackLines[0] || '',
212
+ tags: true,
213
+ style: styles.errorDetail,
214
+ });
215
+ }
216
+
217
+ // Retry count indicator
218
+ if (this.errorState.retryCount > 0) {
219
+ blessed.text({
220
+ parent: this.errorContainer,
221
+ top: currentTop++,
222
+ left: 'center',
223
+ content: `Retry ${this.errorState.retryCount}/${this.options.maxRetries}`,
224
+ tags: true,
225
+ style: styles.errorDetail,
226
+ });
227
+ }
228
+
229
+ // Retry button
230
+ currentTop++;
231
+ this.retryButton = blessed.button({
232
+ parent: this.errorContainer,
233
+ top: currentTop,
234
+ left: 'center',
235
+ width: 12,
236
+ height: 1,
237
+ content: ' Retry ',
238
+ align: 'center',
239
+ valign: 'middle',
240
+ tags: true,
241
+ style: {
242
+ fg: styles.retryButton.fg,
243
+ bg: styles.retryButton.bg,
244
+ bold: styles.retryButton.bold,
245
+ focus: {
246
+ fg: styles.retryButtonFocused.fg,
247
+ bg: styles.retryButtonFocused.bg,
248
+ bold: styles.retryButtonFocused.bold,
249
+ },
250
+ hover: {
251
+ fg: styles.retryButtonFocused.fg,
252
+ bg: styles.retryButtonFocused.bg,
253
+ },
254
+ },
255
+ });
256
+
257
+ this.retryButton.on('press', this.handleRetry);
258
+
259
+ // Dismiss button (if allowed)
260
+ if (this.options.allowDismiss) {
261
+ this.dismissButton = blessed.button({
262
+ parent: this.errorContainer,
263
+ top: currentTop + 2,
264
+ left: 'center',
265
+ width: 12,
266
+ height: 1,
267
+ content: ' Dismiss ',
268
+ align: 'center',
269
+ valign: 'middle',
270
+ tags: true,
271
+ style: {
272
+ fg: styles.dismissButton.fg,
273
+ bg: styles.dismissButton.bg,
274
+ focus: {
275
+ fg: 'black',
276
+ bg: 'white',
277
+ },
278
+ hover: {
279
+ fg: 'black',
280
+ bg: 'white',
281
+ },
282
+ },
283
+ });
284
+
285
+ this.dismissButton.on('press', this.handleDismiss);
286
+ }
287
+
288
+ // Set focus to retry button
289
+ this.retryButton.focus();
290
+
291
+ // Keyboard navigation
292
+ this.parentScreen.on('keypress', this.handleKeypress);
293
+
294
+ this.errorState.hasError = true;
295
+ this.errorState.lastError = originalError;
296
+
297
+ // Notify callback
298
+ if (this.options.onError) {
299
+ this.options.onError(originalError || new Error(message), this.errorState.retryCount);
300
+ }
301
+
302
+ logger.warn(`Error boundary shown for widget '${this.widget.id}': ${message}`);
303
+ }
304
+
305
+ /**
306
+ * Get error styles merged with theme
307
+ * @private
308
+ */
309
+ getErrorStyles(themeColors) {
310
+ return {
311
+ container: {
312
+ border: { type: 'line' },
313
+ style: {
314
+ border: { fg: themeColors.error || 'red' },
315
+ bg: 'black',
316
+ },
317
+ },
318
+ title: {
319
+ fg: themeColors.error || 'red',
320
+ bold: true,
321
+ },
322
+ message: {
323
+ fg: 'white',
324
+ bg: 'black',
325
+ },
326
+ errorDetail: {
327
+ fg: themeColors.gray || 'gray',
328
+ bg: 'black',
329
+ },
330
+ retryButton: ErrorStyles.RETRY_BUTTON,
331
+ retryButtonFocused: ErrorStyles.RETRY_BUTTON_FOCUSED,
332
+ dismissButton: ErrorStyles.DISMISS_BUTTON,
333
+ icon: {
334
+ fg: themeColors.error || 'red',
335
+ bg: 'black',
336
+ },
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Handle keypress for keyboard navigation
342
+ * @private
343
+ */
344
+ handleKeypress(ch, key) {
345
+ if (!this.errorState.hasError) return;
346
+
347
+ if (key.name === 'r' || key.name === 'return') {
348
+ this.handleRetry();
349
+ } else if (key.name === 'd' || key.name === 'escape') {
350
+ this.handleDismiss();
351
+ } else if (key.name === 'tab') {
352
+ // Toggle focus between buttons
353
+ if (this.dismissButton) {
354
+ const focused = this.retryButton.focused;
355
+ if (focused) {
356
+ this.dismissButton.focus();
357
+ } else {
358
+ this.retryButton.focus();
359
+ }
360
+ }
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Handle retry action
366
+ * @private
367
+ */
368
+ async handleRetry() {
369
+ if (this.errorState.isRecovering) return;
370
+
371
+ this.errorState.isRecovering = true;
372
+ this.errorState.retryCount++;
373
+
374
+ logger.info(`Retrying widget '${this.widget.id}' (attempt ${this.errorState.retryCount})`);
375
+
376
+ // Show loading state
377
+ if (this.retryButton) {
378
+ this.retryButton.setContent('Retrying...');
379
+ this.retryButton.style.bg = 'yellow';
380
+ }
381
+
382
+ try {
383
+ // Clear error state
384
+ this.clearErrorBoundary();
385
+
386
+ // Reset isolator health for this widget
387
+ this.isolator.resetWidget(this.widget.id);
388
+
389
+ // Attempt to recreate the widget
390
+ let initResult = null;
391
+ let createResult = null;
392
+
393
+ if (this.widget.init) {
394
+ initResult = await this.isolator.wrapInit(
395
+ this.widget.id,
396
+ () => this.widget.init()
397
+ );
398
+ }
399
+
400
+ if (this.widget.create && initResult !== null) {
401
+ createResult = await this.isolator.wrapCreate(
402
+ this.widget.id,
403
+ () => this.widget.create(this.parentScreen, this.options.theme)
404
+ );
405
+ }
406
+
407
+ // Check if any operation failed (null result indicates failure in isolator)
408
+ if (initResult === null && this.widget.init) {
409
+ throw new Error('Widget initialization failed');
410
+ }
411
+ if (createResult === null && this.widget.create) {
412
+ throw new Error('Widget creation failed');
413
+ }
414
+
415
+ // Success! Update state
416
+ this.errorState.hasError = false;
417
+ this.errorState.error = null;
418
+ this.originalBox = this.widget.box;
419
+
420
+ if (this.options.onRetry) {
421
+ this.options.onRetry(true, this.errorState.retryCount);
422
+ }
423
+
424
+ logger.info(`Widget '${this.widget.id}' recovered successfully`);
425
+ } catch (error) {
426
+ // Retry failed, show error again
427
+ this.errorState.hasError = true;
428
+ this.errorState.error = error;
429
+
430
+ // Check if we should show error again or give up
431
+ if (this.errorState.retryCount >= this.options.maxRetries) {
432
+ this.showErrorBoundary(`Widget failed after ${this.options.maxRetries} retries`, error);
433
+ blessed.text({
434
+ parent: this.errorContainer,
435
+ top: this.errorContainer.children.length - 2,
436
+ left: 'center',
437
+ content: '{red-fg}Max retries reached{/red-fg}',
438
+ tags: true,
439
+ style: { fg: 'red' },
440
+ });
441
+ } else {
442
+ this.showErrorBoundary(error.message || 'Widget failed to recover', error);
443
+ }
444
+
445
+ if (this.options.onRetry) {
446
+ this.options.onRetry(false, this.errorState.retryCount, error);
447
+ }
448
+
449
+ logger.error(`Widget '${this.widget.id}' retry failed: ${error.message}`);
450
+ } finally {
451
+ this.errorState.isRecovering = false;
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Handle dismiss action
457
+ * @private
458
+ */
459
+ handleDismiss() {
460
+ logger.info(`Widget '${this.widget.id}' error boundary dismissed`);
461
+
462
+ this.clearErrorBoundary();
463
+
464
+ if (this.options.onDismiss) {
465
+ this.options.onDismiss();
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Clear the error boundary UI
471
+ * @private
472
+ */
473
+ clearErrorBoundary() {
474
+ // Remove keypress listener
475
+ if (this.parentScreen) {
476
+ this.parentScreen.removeListener('keypress', this.handleKeypress);
477
+ }
478
+
479
+ // Destroy error container
480
+ if (this.errorContainer && !this.errorContainer.destroyed) {
481
+ this.errorContainer.destroy();
482
+ this.errorContainer = null;
483
+ }
484
+
485
+ // Show original widget if it exists
486
+ if (this.originalBox && !this.originalBox.destroyed) {
487
+ this.originalBox.show();
488
+ }
489
+
490
+ this.retryButton = null;
491
+ this.dismissButton = null;
492
+ this.errorText = null;
493
+ }
494
+
495
+ /**
496
+ * Handle error and show boundary
497
+ * @param {Error} error - The error that occurred
498
+ * @param {string} type - Error type
499
+ * @private
500
+ */
501
+ handleError(error, type = WidgetErrorType.UNKNOWN_ERROR) {
502
+ this.errorState.error = error;
503
+ this.errorState.hasError = true;
504
+ this.isolator.healthTracker.recordError(this.widget.id, error, type);
505
+ this.showErrorBoundary(error.message, error);
506
+ }
507
+
508
+ /**
509
+ * Get data with error handling
510
+ */
511
+ async getData(dataProvider) {
512
+ if (this.errorState.hasError) {
513
+ return null;
514
+ }
515
+
516
+ const result = await this.isolator.wrapGetData(
517
+ this.widget.id,
518
+ () => this.widget.getData(dataProvider)
519
+ );
520
+
521
+ // Check if operation failed (result is null and health shows error)
522
+ if (result === null) {
523
+ const health = this.isolator.getHealth(this.widget.id);
524
+ if (health?.lastError && !this.errorState.hasError) {
525
+ const error = new Error(health.lastError.message || 'Data fetch failed');
526
+ error.stack = health.lastError.stack;
527
+ this.handleError(error, WidgetErrorType.DATA_ERROR);
528
+ }
529
+ }
530
+
531
+ return result;
532
+ }
533
+
534
+ /**
535
+ * Render with error handling
536
+ */
537
+ async render(data) {
538
+ if (this.errorState.hasError) {
539
+ return;
540
+ }
541
+
542
+ const result = await this.isolator.wrapRender(
543
+ this.widget.id,
544
+ () => this.widget.render(data)
545
+ );
546
+
547
+ // Check if operation failed (result is null and health shows error)
548
+ if (result === null) {
549
+ const health = this.isolator.getHealth(this.widget.id);
550
+ if (health?.lastError && !this.errorState.hasError) {
551
+ const error = new Error(health.lastError.message || 'Render failed');
552
+ error.stack = health.lastError.stack;
553
+ this.handleError(error, WidgetErrorType.RENDER_ERROR);
554
+ }
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Update with error handling
560
+ */
561
+ update(data) {
562
+ if (this.errorState.hasError || !this.widget.update) {
563
+ return;
564
+ }
565
+
566
+ try {
567
+ this.widget.update(data);
568
+ } catch (error) {
569
+ this.handleError(error, WidgetErrorType.RENDER_ERROR);
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Destroy the error boundary and widget
575
+ */
576
+ async destroy() {
577
+ this.clearErrorBoundary();
578
+
579
+ try {
580
+ await this.isolator.wrapDestroy(
581
+ this.widget.id,
582
+ () => this.widget.destroy()
583
+ );
584
+ } catch (error) {
585
+ logger.error(`Error destroying widget '${this.widget.id}': ${error.message}`);
586
+ }
587
+
588
+ this.isolator.shutdown();
589
+ }
590
+
591
+ /**
592
+ * Get current error state
593
+ */
594
+ getErrorState() {
595
+ return {
596
+ ...this.errorState,
597
+ health: this.isolator.getHealth(this.widget.id),
598
+ };
599
+ }
600
+
601
+ /**
602
+ * Check if widget is in error state
603
+ */
604
+ hasError() {
605
+ return this.errorState.hasError;
606
+ }
607
+
608
+ /**
609
+ * Force show error boundary with custom message
610
+ * @param {string} message - Error message
611
+ * @param {Error} error - Optional error object
612
+ */
613
+ showError(message, error = null) {
614
+ this.errorState.hasError = true;
615
+ this.errorState.error = error || new Error(message);
616
+ this.errorState.lastError = error || new Error(message);
617
+ if (this.parentScreen) {
618
+ this.showErrorBoundary(message, error);
619
+ } else {
620
+ // Store message for later display when screen is available
621
+ this._pendingErrorMessage = message;
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Reset error state
627
+ */
628
+ async reset() {
629
+ this.clearErrorBoundary();
630
+ this.errorState.hasError = false;
631
+ this.errorState.error = null;
632
+ this.errorState.retryCount = 0;
633
+ this.isolator.resetWidget(this.widget.id);
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Create an error boundary for a widget
639
+ * @param {Object} widget - Widget instance to wrap
640
+ * @param {Object} options - Error boundary options
641
+ * @returns {WidgetErrorBoundary} Error boundary instance
642
+ */
643
+ export function withErrorBoundary(widget, options = {}) {
644
+ return new WidgetErrorBoundary(widget, options);
645
+ }
646
+
647
+ /**
648
+ * Error boundary manager for managing multiple widget error boundaries
649
+ */
650
+ export class ErrorBoundaryManager {
651
+ constructor() {
652
+ this.boundaries = new Map();
653
+ this.globalOptions = {};
654
+ }
655
+
656
+ /**
657
+ * Set global options for all error boundaries
658
+ * @param {Object} options - Global options
659
+ */
660
+ setGlobalOptions(options) {
661
+ this.globalOptions = { ...this.globalOptions, ...options };
662
+ }
663
+
664
+ /**
665
+ * Wrap a widget with error boundary
666
+ * @param {Object} widget - Widget to wrap
667
+ * @param {Object} options - Options (merged with global options)
668
+ * @returns {WidgetErrorBoundary} Error boundary instance
669
+ */
670
+ wrap(widget, options = {}) {
671
+ const mergedOptions = { ...this.globalOptions, ...options };
672
+ const boundary = new WidgetErrorBoundary(widget, mergedOptions);
673
+ this.boundaries.set(widget.id, boundary);
674
+ return boundary;
675
+ }
676
+
677
+ /**
678
+ * Get error boundary for a widget
679
+ * @param {string} widgetId - Widget ID
680
+ * @returns {WidgetErrorBoundary|null} Error boundary or null
681
+ */
682
+ get(widgetId) {
683
+ return this.boundaries.get(widgetId) || null;
684
+ }
685
+
686
+ /**
687
+ * Remove an error boundary
688
+ * @param {string} widgetId - Widget ID
689
+ */
690
+ remove(widgetId) {
691
+ const boundary = this.boundaries.get(widgetId);
692
+ if (boundary) {
693
+ boundary.destroy();
694
+ this.boundaries.delete(widgetId);
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Get all error states
700
+ * @returns {Object} Map of widget ID to error state
701
+ */
702
+ getAllErrorStates() {
703
+ const states = {};
704
+ for (const [id, boundary] of this.boundaries) {
705
+ states[id] = boundary.getErrorState();
706
+ }
707
+ return states;
708
+ }
709
+
710
+ /**
711
+ * Retry all failed widgets
712
+ * @returns {Promise<Object>} Results of retry attempts
713
+ */
714
+ async retryAll() {
715
+ const results = {};
716
+ for (const [id, boundary] of this.boundaries) {
717
+ if (boundary.hasError()) {
718
+ try {
719
+ await boundary.handleRetry();
720
+ results[id] = { success: true };
721
+ } catch (error) {
722
+ results[id] = { success: false, error: error.message };
723
+ }
724
+ }
725
+ }
726
+ return results;
727
+ }
728
+
729
+ /**
730
+ * Clear all error boundaries
731
+ */
732
+ clearAll() {
733
+ for (const boundary of this.boundaries.values()) {
734
+ boundary.destroy();
735
+ }
736
+ this.boundaries.clear();
737
+ }
738
+
739
+ /**
740
+ * Get statistics
741
+ * @returns {Object} Statistics
742
+ */
743
+ getStats() {
744
+ const all = Array.from(this.boundaries.values());
745
+ return {
746
+ total: all.length,
747
+ inError: all.filter(b => b.hasError()).length,
748
+ healthy: all.filter(b => !b.hasError()).length,
749
+ };
750
+ }
751
+ }
752
+
753
+ // Singleton instance
754
+ let defaultManager = null;
755
+
756
+ /**
757
+ * Get default error boundary manager
758
+ * @returns {ErrorBoundaryManager} Default manager instance
759
+ */
760
+ export function getErrorBoundaryManager() {
761
+ if (!defaultManager) {
762
+ defaultManager = new ErrorBoundaryManager();
763
+ }
764
+ return defaultManager;
765
+ }
766
+
767
+ export default {
768
+ WidgetErrorBoundary,
769
+ ErrorBoundaryManager,
770
+ ErrorStyles,
771
+ withErrorBoundary,
772
+ getErrorBoundaryManager,
773
+ };