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