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