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,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animated Loading States Module
|
|
3
|
+
* Provides spinners, progress indicators, and animated transitions
|
|
4
|
+
* for widget loading, data fetching, and initialization states
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import logger from './logger.js';
|
|
8
|
+
import { getCurrentTheme } from './themes.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Spinner frame sets for different animation styles
|
|
12
|
+
*/
|
|
13
|
+
const SPINNER_FRAMES = {
|
|
14
|
+
dots: {
|
|
15
|
+
frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
|
16
|
+
interval: 80
|
|
17
|
+
},
|
|
18
|
+
line: {
|
|
19
|
+
frames: ['-', '\\', '|', '/'],
|
|
20
|
+
interval: 100
|
|
21
|
+
},
|
|
22
|
+
pulse: {
|
|
23
|
+
frames: ['○', '◔', '◑', '◕', '●', '◕', '◑', '◔'],
|
|
24
|
+
interval: 100
|
|
25
|
+
},
|
|
26
|
+
blocks: {
|
|
27
|
+
frames: ['▁', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃'],
|
|
28
|
+
interval: 80
|
|
29
|
+
},
|
|
30
|
+
arrows: {
|
|
31
|
+
frames: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'],
|
|
32
|
+
interval: 100
|
|
33
|
+
},
|
|
34
|
+
bouncing: {
|
|
35
|
+
frames: ['( ● )', '( ● )', '( ● )', '( ● )', '( ●)', '( ● )', '( ● )', '( ● )', '( ● )', '(● )'],
|
|
36
|
+
interval: 80
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Progress bar styles
|
|
42
|
+
*/
|
|
43
|
+
const PROGRESS_STYLES = {
|
|
44
|
+
blocks: ['░', '▒', '▓', '█'],
|
|
45
|
+
bars: [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'],
|
|
46
|
+
ascii: [' ', '=', '=','=','=','=','=','=','#'],
|
|
47
|
+
dots: [' ', '·', '∙', '●'],
|
|
48
|
+
minimal: ['○', '◐', '◑', '●']
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Loading state manager for tracking active loading operations
|
|
53
|
+
*/
|
|
54
|
+
class LoadingStateManager {
|
|
55
|
+
constructor() {
|
|
56
|
+
this.activeStates = new Map();
|
|
57
|
+
this.globalSpinner = null;
|
|
58
|
+
this.animationFrameId = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create a new loading state
|
|
63
|
+
* @param {string} id - Unique identifier for this loading state
|
|
64
|
+
* @param {Object} options - Loading state options
|
|
65
|
+
* @param {string} options.type - Type: 'spinner', 'progress', 'pulse', 'custom'
|
|
66
|
+
* @param {string} options.message - Loading message to display
|
|
67
|
+
* @param {string} options.style - Spinner/progress style name
|
|
68
|
+
* @param {number} options.total - Total for progress bars
|
|
69
|
+
* @returns {Object} Loading state controller
|
|
70
|
+
*/
|
|
71
|
+
create(id, options = {}) {
|
|
72
|
+
const {
|
|
73
|
+
type = 'spinner',
|
|
74
|
+
message = 'Loading...',
|
|
75
|
+
style = 'dots',
|
|
76
|
+
total = 100
|
|
77
|
+
} = options;
|
|
78
|
+
|
|
79
|
+
const state = {
|
|
80
|
+
id,
|
|
81
|
+
type,
|
|
82
|
+
message,
|
|
83
|
+
style,
|
|
84
|
+
total,
|
|
85
|
+
current: 0,
|
|
86
|
+
frames: SPINNER_FRAMES[style]?.frames || SPINNER_FRAMES.dots.frames,
|
|
87
|
+
frameIndex: 0,
|
|
88
|
+
interval: SPINNER_FRAMES[style]?.interval || 80,
|
|
89
|
+
startTime: Date.now(),
|
|
90
|
+
timerId: null,
|
|
91
|
+
listeners: new Set(),
|
|
92
|
+
isComplete: false
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
this.activeStates.set(id, state);
|
|
96
|
+
|
|
97
|
+
// Start animation
|
|
98
|
+
if (type === 'spinner') {
|
|
99
|
+
this._startSpinnerAnimation(state);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
logger.debug(`Loading state created: ${id} (${type})`);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
id,
|
|
106
|
+
update: (newMessage) => this.updateMessage(id, newMessage),
|
|
107
|
+
progress: (current, newTotal) => this.updateProgress(id, current, newTotal),
|
|
108
|
+
complete: (finalMessage) => this.complete(id, finalMessage),
|
|
109
|
+
onUpdate: (callback) => this._addListener(id, callback),
|
|
110
|
+
getFrame: () => this._getCurrentFrame(state),
|
|
111
|
+
elapsed: () => Date.now() - state.startTime
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Start spinner animation timer
|
|
117
|
+
* @private
|
|
118
|
+
*/
|
|
119
|
+
_startSpinnerAnimation(state) {
|
|
120
|
+
state.timerId = setInterval(() => {
|
|
121
|
+
state.frameIndex = (state.frameIndex + 1) % state.frames.length;
|
|
122
|
+
this._notifyListeners(state);
|
|
123
|
+
}, state.interval);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get current spinner frame
|
|
128
|
+
* @private
|
|
129
|
+
*/
|
|
130
|
+
_getCurrentFrame(state) {
|
|
131
|
+
const theme = getCurrentTheme();
|
|
132
|
+
const colors = theme.colors;
|
|
133
|
+
const frame = state.frames[state.frameIndex];
|
|
134
|
+
|
|
135
|
+
if (state.type === 'progress') {
|
|
136
|
+
return this._renderProgressBar(state, colors);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Return formatted spinner with message
|
|
140
|
+
return {
|
|
141
|
+
frame,
|
|
142
|
+
message: state.message,
|
|
143
|
+
elapsed: this._formatElapsed(Date.now() - state.startTime),
|
|
144
|
+
color: colors.branding.logo
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Render progress bar
|
|
150
|
+
* @private
|
|
151
|
+
*/
|
|
152
|
+
_renderProgressBar(state, colors) {
|
|
153
|
+
const { bars } = PROGRESS_STYLES;
|
|
154
|
+
const percentage = Math.min(100, Math.max(0, (state.current / state.total) * 100));
|
|
155
|
+
const filledLength = Math.floor((percentage / 100) * bars.length);
|
|
156
|
+
|
|
157
|
+
const filled = bars[bars.length - 1].repeat(filledLength);
|
|
158
|
+
const empty = bars[0].repeat(bars.length - filledLength);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
bar: `[${filled}${empty}]`,
|
|
162
|
+
percentage: percentage.toFixed(1),
|
|
163
|
+
current: state.current,
|
|
164
|
+
total: state.total,
|
|
165
|
+
message: state.message,
|
|
166
|
+
color: percentage < 30 ? colors.gauge.low :
|
|
167
|
+
percentage < 70 ? colors.gauge.medium :
|
|
168
|
+
percentage < 90 ? colors.gauge.high : colors.gauge.critical
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Format elapsed time
|
|
174
|
+
* @private
|
|
175
|
+
*/
|
|
176
|
+
_formatElapsed(ms) {
|
|
177
|
+
if (ms < 1000) return `${ms}ms`;
|
|
178
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
179
|
+
const mins = Math.floor(ms / 60000);
|
|
180
|
+
const secs = ((ms % 60000) / 1000).toFixed(0);
|
|
181
|
+
return `${mins}m ${secs}s`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Add update listener
|
|
186
|
+
* @private
|
|
187
|
+
*/
|
|
188
|
+
_addListener(id, callback) {
|
|
189
|
+
const state = this.activeStates.get(id);
|
|
190
|
+
if (state) {
|
|
191
|
+
state.listeners.add(callback);
|
|
192
|
+
return () => state.listeners.delete(callback);
|
|
193
|
+
}
|
|
194
|
+
return () => {};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Notify all listeners of state update
|
|
199
|
+
* @private
|
|
200
|
+
*/
|
|
201
|
+
_notifyListeners(state) {
|
|
202
|
+
const frame = this._getCurrentFrame(state);
|
|
203
|
+
state.listeners.forEach(callback => {
|
|
204
|
+
try {
|
|
205
|
+
callback(frame, state);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
logger.debug(`Loading state listener error: ${err.message}`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Update loading message
|
|
214
|
+
* @param {string} id - Loading state ID
|
|
215
|
+
* @param {string} newMessage - New message to display
|
|
216
|
+
*/
|
|
217
|
+
updateMessage(id, newMessage) {
|
|
218
|
+
const state = this.activeStates.get(id);
|
|
219
|
+
if (state) {
|
|
220
|
+
state.message = newMessage;
|
|
221
|
+
this._notifyListeners(state);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Update progress bar
|
|
227
|
+
* @param {string} id - Loading state ID
|
|
228
|
+
* @param {number} current - Current progress value
|
|
229
|
+
* @param {number} newTotal - Optional new total
|
|
230
|
+
*/
|
|
231
|
+
updateProgress(id, current, newTotal) {
|
|
232
|
+
const state = this.activeStates.get(id);
|
|
233
|
+
if (state) {
|
|
234
|
+
state.current = current;
|
|
235
|
+
if (newTotal !== undefined) state.total = newTotal;
|
|
236
|
+
this._notifyListeners(state);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Mark loading state as complete
|
|
242
|
+
* @param {string} id - Loading state ID
|
|
243
|
+
* @param {string} finalMessage - Optional final message
|
|
244
|
+
*/
|
|
245
|
+
complete(id, finalMessage) {
|
|
246
|
+
const state = this.activeStates.get(id);
|
|
247
|
+
if (state) {
|
|
248
|
+
state.isComplete = true;
|
|
249
|
+
if (state.timerId) {
|
|
250
|
+
clearInterval(state.timerId);
|
|
251
|
+
state.timerId = null;
|
|
252
|
+
}
|
|
253
|
+
if (finalMessage) {
|
|
254
|
+
state.message = finalMessage;
|
|
255
|
+
}
|
|
256
|
+
this._notifyListeners(state);
|
|
257
|
+
logger.debug(`Loading state completed: ${id} (${this._formatElapsed(Date.now() - state.startTime)})`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Remove a loading state
|
|
263
|
+
* @param {string} id - Loading state ID
|
|
264
|
+
*/
|
|
265
|
+
remove(id) {
|
|
266
|
+
const state = this.activeStates.get(id);
|
|
267
|
+
if (state) {
|
|
268
|
+
if (state.timerId) {
|
|
269
|
+
clearInterval(state.timerId);
|
|
270
|
+
}
|
|
271
|
+
this.activeStates.delete(id);
|
|
272
|
+
logger.debug(`Loading state removed: ${id}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Get all active loading states
|
|
278
|
+
* @returns {Array} Array of active state IDs
|
|
279
|
+
*/
|
|
280
|
+
getActive() {
|
|
281
|
+
return Array.from(this.activeStates.keys());
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Clear all loading states
|
|
286
|
+
*/
|
|
287
|
+
clearAll() {
|
|
288
|
+
for (const [id, state] of this.activeStates) {
|
|
289
|
+
if (state.timerId) {
|
|
290
|
+
clearInterval(state.timerId);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
this.activeStates.clear();
|
|
294
|
+
logger.debug('All loading states cleared');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Create singleton instance
|
|
299
|
+
const loadingStates = new LoadingStateManager();
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Create a loading spinner for widget initialization
|
|
303
|
+
* @param {string} widgetName - Name of the widget being loaded
|
|
304
|
+
* @param {Object} blessed - Blessed library instance
|
|
305
|
+
* @param {Object} parent - Parent element
|
|
306
|
+
* @returns {Object} Spinner controller with attach/detach methods
|
|
307
|
+
*/
|
|
308
|
+
export function createWidgetSpinner(widgetName, blessed, parent) {
|
|
309
|
+
const state = loadingStates.create(`widget:${widgetName}`, {
|
|
310
|
+
type: 'spinner',
|
|
311
|
+
message: `Loading ${widgetName}...`,
|
|
312
|
+
style: 'dots'
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
let spinnerElement = null;
|
|
316
|
+
|
|
317
|
+
const controller = {
|
|
318
|
+
/**
|
|
319
|
+
* Attach spinner to UI
|
|
320
|
+
* @param {Object} options - Position options
|
|
321
|
+
*/
|
|
322
|
+
attach(options = {}) {
|
|
323
|
+
const theme = getCurrentTheme();
|
|
324
|
+
const { top = 'center', left = 'center', width = 30, height = 3 } = options;
|
|
325
|
+
|
|
326
|
+
spinnerElement = blessed.box({
|
|
327
|
+
parent,
|
|
328
|
+
top,
|
|
329
|
+
left,
|
|
330
|
+
width,
|
|
331
|
+
height,
|
|
332
|
+
content: `{center}${state.getFrame().frame} ${state.message}{/center}`,
|
|
333
|
+
tags: true,
|
|
334
|
+
style: {
|
|
335
|
+
fg: theme.colors.branding.logo,
|
|
336
|
+
bg: theme.colors.footer.bg
|
|
337
|
+
},
|
|
338
|
+
border: {
|
|
339
|
+
type: 'line',
|
|
340
|
+
fg: theme.colors.border.modal
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Subscribe to updates
|
|
345
|
+
state.onUpdate((frame) => {
|
|
346
|
+
if (spinnerElement && !spinnerElement.destroyed) {
|
|
347
|
+
spinnerElement.setContent(`{center}${frame.frame} ${frame.message}{/center}`);
|
|
348
|
+
spinnerElement.style.fg = frame.color;
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
parent.screen.render();
|
|
353
|
+
return controller;
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Update spinner message
|
|
358
|
+
* @param {string} message - New message
|
|
359
|
+
*/
|
|
360
|
+
update(message) {
|
|
361
|
+
state.update(message);
|
|
362
|
+
return controller;
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Complete and remove spinner
|
|
367
|
+
* @param {string} finalMessage - Optional final message
|
|
368
|
+
*/
|
|
369
|
+
complete(finalMessage) {
|
|
370
|
+
state.complete(finalMessage);
|
|
371
|
+
if (spinnerElement) {
|
|
372
|
+
if (finalMessage) {
|
|
373
|
+
spinnerElement.setContent(`{center}✓ ${finalMessage}{/center}`);
|
|
374
|
+
spinnerElement.style.fg = 'green';
|
|
375
|
+
parent.screen.render();
|
|
376
|
+
// Auto-remove after delay
|
|
377
|
+
setTimeout(() => controller.detach(), 500);
|
|
378
|
+
} else {
|
|
379
|
+
controller.detach();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return controller;
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Detach and destroy spinner
|
|
387
|
+
*/
|
|
388
|
+
detach() {
|
|
389
|
+
if (spinnerElement) {
|
|
390
|
+
spinnerElement.destroy();
|
|
391
|
+
spinnerElement = null;
|
|
392
|
+
parent.screen.render();
|
|
393
|
+
}
|
|
394
|
+
loadingStates.remove(state.id);
|
|
395
|
+
return controller;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
return controller;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Create a progress bar for data fetching operations
|
|
404
|
+
* @param {string} operationName - Name of the operation
|
|
405
|
+
* @param {number} total - Total items to process
|
|
406
|
+
* @returns {Object} Progress controller
|
|
407
|
+
*/
|
|
408
|
+
export function createProgressBar(operationName, total = 100) {
|
|
409
|
+
return loadingStates.create(`progress:${operationName}`, {
|
|
410
|
+
type: 'progress',
|
|
411
|
+
message: operationName,
|
|
412
|
+
style: 'blocks',
|
|
413
|
+
total
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Create a sequential loading animation for multiple items
|
|
419
|
+
* @param {Array} items - Array of items to load
|
|
420
|
+
* @param {Function} loader - Async function to load each item
|
|
421
|
+
* @param {Object} options - Options
|
|
422
|
+
* @returns {Promise<Array>} Results from all loaders
|
|
423
|
+
*/
|
|
424
|
+
export async function loadSequentially(items, loader, options = {}) {
|
|
425
|
+
const { onProgress, onItemComplete, delay = 0 } = options;
|
|
426
|
+
const results = [];
|
|
427
|
+
|
|
428
|
+
const state = loadingStates.create('sequential', {
|
|
429
|
+
type: 'progress',
|
|
430
|
+
message: 'Loading...',
|
|
431
|
+
total: items.length
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
for (let i = 0; i < items.length; i++) {
|
|
435
|
+
const item = items[i];
|
|
436
|
+
state.update(`Loading ${item.name || item.id || item}...`);
|
|
437
|
+
state.progress(i);
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
const result = await loader(item, i);
|
|
441
|
+
results.push({ success: true, result, item, index: i });
|
|
442
|
+
if (onItemComplete) {
|
|
443
|
+
onItemComplete(item, result, i);
|
|
444
|
+
}
|
|
445
|
+
} catch (err) {
|
|
446
|
+
results.push({ success: false, error: err, item, index: i });
|
|
447
|
+
logger.warn(`Failed to load item ${i}: ${err.message}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (onProgress) {
|
|
451
|
+
onProgress(i + 1, items.length, item);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (delay > 0 && i < items.length - 1) {
|
|
455
|
+
await new Promise(r => setTimeout(r, delay));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
state.progress(items.length, items.length);
|
|
460
|
+
state.complete('Complete');
|
|
461
|
+
loadingStates.remove('sequential');
|
|
462
|
+
|
|
463
|
+
return results;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Create a staggered loading animation for multiple widgets
|
|
468
|
+
* @param {Array} widgets - Array of widget configurations
|
|
469
|
+
* @param {Function} factory - Widget factory function
|
|
470
|
+
* @param {Object} options - Options
|
|
471
|
+
* @returns {Promise<Array>} Created widgets
|
|
472
|
+
*/
|
|
473
|
+
export async function loadStaggered(widgets, factory, options = {}) {
|
|
474
|
+
const { staggerDelay = 100, onWidgetLoaded } = options;
|
|
475
|
+
|
|
476
|
+
return loadSequentially(widgets, async (widgetConfig, index) => {
|
|
477
|
+
await new Promise(r => setTimeout(r, staggerDelay * index));
|
|
478
|
+
const widget = await factory(widgetConfig);
|
|
479
|
+
if (onWidgetLoaded) {
|
|
480
|
+
onWidgetLoaded(widget, widgetConfig, index);
|
|
481
|
+
}
|
|
482
|
+
return widget;
|
|
483
|
+
}, options);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Get a simple spinner animation frame
|
|
488
|
+
* @param {string} style - Spinner style name
|
|
489
|
+
* @param {number} frame - Frame index (auto-increments if not provided)
|
|
490
|
+
* @returns {string} Spinner character
|
|
491
|
+
*/
|
|
492
|
+
export function getSpinnerFrame(style = 'dots', frame) {
|
|
493
|
+
const spinner = SPINNER_FRAMES[style] || SPINNER_FRAMES.dots;
|
|
494
|
+
if (frame === undefined) {
|
|
495
|
+
frame = Math.floor(Date.now() / spinner.interval) % spinner.frames.length;
|
|
496
|
+
}
|
|
497
|
+
return spinner.frames[frame % spinner.frames.length];
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Get all available spinner styles
|
|
502
|
+
* @returns {Array} Array of style names
|
|
503
|
+
*/
|
|
504
|
+
export function getSpinnerStyles() {
|
|
505
|
+
return Object.keys(SPINNER_FRAMES);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export default loadingStates;
|
|
509
|
+
export { SPINNER_FRAMES, PROGRESS_STYLES, LoadingStateManager };
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger module with timestamp support and file output
|
|
3
|
+
* Replaces direct console.error usage throughout the codebase
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANT: Logs are written to file only to avoid interfering with blessed TUI
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import { setSecurePermissionsSync } from './security.js';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { dirname, join } from 'path';
|
|
13
|
+
|
|
14
|
+
// Get the directory of this module to resolve the log path
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
|
|
18
|
+
// Log file path - using ~/.openclaw/claw-dashboard.log
|
|
19
|
+
const LOG_FILE_PATH = os.homedir() + '/.openclaw/claw-dashboard.log';
|
|
20
|
+
|
|
21
|
+
// Ensure log directory exists
|
|
22
|
+
function ensureLogDir() {
|
|
23
|
+
const logDir = os.homedir() + '/.openclaw';
|
|
24
|
+
if (!fs.existsSync(logDir)) {
|
|
25
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sanitize user-controlled input to prevent log injection attacks
|
|
31
|
+
* - Escapes newlines and carriage returns
|
|
32
|
+
* - Removes or escapes ANSI control codes
|
|
33
|
+
* - Escapes other special characters that could affect log formatting
|
|
34
|
+
*
|
|
35
|
+
* @param {any} value - The value to sanitize
|
|
36
|
+
* @returns {string} Sanitized string representation
|
|
37
|
+
*/
|
|
38
|
+
function sanitize(value) {
|
|
39
|
+
if (value === null || value === undefined) {
|
|
40
|
+
return String(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Convert to string
|
|
44
|
+
let str = String(value);
|
|
45
|
+
|
|
46
|
+
// Remove ANSI escape sequences (color codes, cursor movement, etc.)
|
|
47
|
+
// These patterns match common ANSI control sequences
|
|
48
|
+
str = str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
49
|
+
str = str.replace(/\x1b\][^\x07]*\x07/g, ''); // OSC sequences
|
|
50
|
+
str = str.replace(/\x1b[P][a-zA-Z0-9]/g, ''); // DCS sequences
|
|
51
|
+
str = str.replace(/\x1b\[[0-9;]*[@-~]/g, ''); // Generic CSI sequences
|
|
52
|
+
|
|
53
|
+
// Escape or remove control characters (except common safe ones)
|
|
54
|
+
str = str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, (char) => {
|
|
55
|
+
// Allow tab and newline but escape them for safety
|
|
56
|
+
if (char === '\t') return '\t';
|
|
57
|
+
if (char === '\n') return '\\n';
|
|
58
|
+
if (char === '\r') return '\\r';
|
|
59
|
+
// Replace other control chars with hex representation
|
|
60
|
+
return '\\x' + char.charCodeAt(0).toString(16).padStart(2, '0');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Replace newlines with escaped versions to prevent log injection
|
|
64
|
+
// But keep them readable in the log
|
|
65
|
+
str = str.replace(/\r\n/g, '\\r\\n');
|
|
66
|
+
str = str.replace(/\n/g, '\\n');
|
|
67
|
+
str = str.replace(/\r/g, '\\r');
|
|
68
|
+
|
|
69
|
+
return str;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sanitize all arguments in a log call
|
|
74
|
+
* @param {any[]} args - Arguments to sanitize
|
|
75
|
+
* @returns {string[]} Array of sanitized strings
|
|
76
|
+
*/
|
|
77
|
+
function sanitizeArgs(args) {
|
|
78
|
+
return args.map(arg => {
|
|
79
|
+
if (typeof arg === 'object') {
|
|
80
|
+
// For objects, try to serialize and sanitize
|
|
81
|
+
try {
|
|
82
|
+
return sanitize(JSON.stringify(arg));
|
|
83
|
+
} catch {
|
|
84
|
+
return sanitize(String(arg));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return sanitize(arg);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write a formatted log line to the log file
|
|
93
|
+
* @param {string} level - Log level (ERROR, WARN, INFO, DEBUG)
|
|
94
|
+
* @param {any[]} args - Arguments to log
|
|
95
|
+
*/
|
|
96
|
+
function writeLog(level, args) {
|
|
97
|
+
const timestamp = getTimestamp();
|
|
98
|
+
const sanitizedArgs = sanitizeArgs(args);
|
|
99
|
+
const message = sanitizedArgs.join(' ');
|
|
100
|
+
const logLine = `${timestamp} [${level}] ${message}\n`;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
ensureLogDir();
|
|
104
|
+
// Avoid TOCTOU by using appendFileSync which creates if needed
|
|
105
|
+
// Track if this is likely first write via file existence
|
|
106
|
+
let isNewFile = false;
|
|
107
|
+
try {
|
|
108
|
+
fs.accessSync(LOG_FILE_PATH, fs.constants.F_OK);
|
|
109
|
+
} catch {
|
|
110
|
+
isNewFile = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fs.appendFileSync(LOG_FILE_PATH, logLine);
|
|
114
|
+
|
|
115
|
+
// Set secure permissions on new files (not TOCTOU vulnerable since we just created it)
|
|
116
|
+
if (isNewFile) {
|
|
117
|
+
setSecurePermissionsSync(LOG_FILE_PATH);
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Silently fail if we can't write to log file - don't disrupt the dashboard
|
|
121
|
+
// Could also try console.error for critical errors, but that defeats the purpose
|
|
122
|
+
if (level === 'ERROR') {
|
|
123
|
+
// For critical errors, at least try to indicate something is wrong
|
|
124
|
+
process.stderr.write(`[Log Error] Failed to write ERROR log: ${err.message}\n`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Format a timestamp for log messages
|
|
131
|
+
* @returns {string} Formatted timestamp [YYYY-MM-DD HH:mm:ss]
|
|
132
|
+
*/
|
|
133
|
+
function getTimestamp() {
|
|
134
|
+
const now = new Date();
|
|
135
|
+
const year = now.getFullYear();
|
|
136
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
137
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
138
|
+
const hours = String(now.getHours()).padStart(2, '0');
|
|
139
|
+
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
140
|
+
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
141
|
+
return `[${year}-${month}-${day} ${hours}:${minutes}:${seconds}]`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Logger object with level-based logging methods
|
|
146
|
+
* All logs are written to file only to prevent interfering with blessed TUI
|
|
147
|
+
*/
|
|
148
|
+
const logger = {
|
|
149
|
+
/**
|
|
150
|
+
* Log error level messages to file
|
|
151
|
+
* @param {...any} args - Arguments to log
|
|
152
|
+
*/
|
|
153
|
+
error(...args) {
|
|
154
|
+
writeLog('ERROR', args);
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Log warning level messages to file
|
|
159
|
+
* @param {...any} args - Arguments to log
|
|
160
|
+
*/
|
|
161
|
+
warn(...args) {
|
|
162
|
+
writeLog('WARN', args);
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Log info level messages to file
|
|
167
|
+
* @param {...any} args - Arguments to log
|
|
168
|
+
*/
|
|
169
|
+
info(...args) {
|
|
170
|
+
writeLog('INFO', args);
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Log debug level messages to file (only when DEBUG env var is set)
|
|
175
|
+
* @param {...any} args - Arguments to log
|
|
176
|
+
*/
|
|
177
|
+
debug(...args) {
|
|
178
|
+
if (process.env.DEBUG) {
|
|
179
|
+
writeLog('DEBUG', args);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export default logger;
|
|
185
|
+
export { logger, LOG_FILE_PATH };
|