@principal-ai/principal-view-core 0.26.12 → 0.26.13
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/dist/dashboard/DashboardValidator.d.ts +85 -0
- package/dist/dashboard/DashboardValidator.d.ts.map +1 -0
- package/dist/dashboard/DashboardValidator.js +772 -0
- package/dist/dashboard/DashboardValidator.js.map +1 -0
- package/dist/dashboard/index.d.ts +6 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +10 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/discovery/CanvasDiscovery.d.ts +7 -0
- package/dist/discovery/CanvasDiscovery.d.ts.map +1 -1
- package/dist/discovery/CanvasDiscovery.js +41 -5
- package/dist/discovery/CanvasDiscovery.js.map +1 -1
- package/dist/discovery/types.d.ts +11 -2
- package/dist/discovery/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +2 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +6 -2
- package/dist/node.js.map +1 -1
- package/package.json +1 -1
- package/src/dashboard/DashboardValidator.ts +901 -0
- package/src/dashboard/index.ts +14 -0
- package/src/discovery/CanvasDiscovery.ts +56 -3
- package/src/discovery/types.ts +12 -2
- package/src/index.ts +8 -0
- package/src/node.ts +8 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Dashboard File Validator
|
|
4
|
+
*
|
|
5
|
+
* Validates dashboard definition files (.dashboard.json) that define metrics,
|
|
6
|
+
* layout, and data sources for observability dashboards.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.createDashboardValidator = exports.DashboardValidator = void 0;
|
|
10
|
+
// Valid metric types
|
|
11
|
+
const VALID_METRIC_TYPES = ['counter', 'gauge', 'histogram'];
|
|
12
|
+
// Valid derivations
|
|
13
|
+
const VALID_DERIVATIONS = [
|
|
14
|
+
'count',
|
|
15
|
+
'rate',
|
|
16
|
+
'sum',
|
|
17
|
+
'avg',
|
|
18
|
+
'min',
|
|
19
|
+
'max',
|
|
20
|
+
'duration',
|
|
21
|
+
'error_rate',
|
|
22
|
+
'success_rate',
|
|
23
|
+
'percentage',
|
|
24
|
+
'p50',
|
|
25
|
+
'p95',
|
|
26
|
+
'p99',
|
|
27
|
+
];
|
|
28
|
+
// Valid time groups
|
|
29
|
+
const VALID_TIME_GROUPS = ['minute', 'hour', 'day', 'week', 'month'];
|
|
30
|
+
// Valid display components
|
|
31
|
+
const VALID_DISPLAY_COMPONENTS = [
|
|
32
|
+
'MetricCard',
|
|
33
|
+
'LineChart',
|
|
34
|
+
'BarChart',
|
|
35
|
+
'StackedBarChart',
|
|
36
|
+
'PieChart',
|
|
37
|
+
'GaugeChart',
|
|
38
|
+
'Histogram',
|
|
39
|
+
'DataTable',
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Validator for dashboard definition files
|
|
43
|
+
*/
|
|
44
|
+
class DashboardValidator {
|
|
45
|
+
/**
|
|
46
|
+
* Validate dashboard definition structure
|
|
47
|
+
*/
|
|
48
|
+
validate(data, filePath, context) {
|
|
49
|
+
const errors = [];
|
|
50
|
+
const warnings = [];
|
|
51
|
+
// Check if data is an object
|
|
52
|
+
if (!data || typeof data !== 'object') {
|
|
53
|
+
errors.push({
|
|
54
|
+
path: filePath || 'root',
|
|
55
|
+
message: 'Dashboard data must be an object',
|
|
56
|
+
severity: 'error',
|
|
57
|
+
suggestion: 'Expected format: { "id": "...", "name": "...", "metrics": [...], "layout": {...} }',
|
|
58
|
+
});
|
|
59
|
+
return { valid: false, errors, warnings };
|
|
60
|
+
}
|
|
61
|
+
// Check if it's an array (common mistake)
|
|
62
|
+
if (Array.isArray(data)) {
|
|
63
|
+
errors.push({
|
|
64
|
+
path: filePath || 'root',
|
|
65
|
+
message: 'Dashboard data should be an object, not an array',
|
|
66
|
+
severity: 'error',
|
|
67
|
+
});
|
|
68
|
+
return { valid: false, errors, warnings };
|
|
69
|
+
}
|
|
70
|
+
const dashboard = data;
|
|
71
|
+
// Validate required fields
|
|
72
|
+
if (!dashboard.id) {
|
|
73
|
+
errors.push({
|
|
74
|
+
path: 'id',
|
|
75
|
+
message: 'Missing required "id" field',
|
|
76
|
+
severity: 'error',
|
|
77
|
+
suggestion: 'Add a unique kebab-case identifier, e.g., "service-health"',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else if (typeof dashboard.id !== 'string') {
|
|
81
|
+
errors.push({
|
|
82
|
+
path: 'id',
|
|
83
|
+
message: '"id" must be a string',
|
|
84
|
+
severity: 'error',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else if (!/^[a-z0-9-]+$/.test(dashboard.id)) {
|
|
88
|
+
warnings.push({
|
|
89
|
+
path: 'id',
|
|
90
|
+
message: 'Dashboard "id" should be kebab-case (lowercase with hyphens)',
|
|
91
|
+
severity: 'warning',
|
|
92
|
+
suggestion: `Consider renaming to "${dashboard.id.toLowerCase().replace(/[^a-z0-9]+/g, '-')}"`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (!dashboard.name) {
|
|
96
|
+
errors.push({
|
|
97
|
+
path: 'name',
|
|
98
|
+
message: 'Missing required "name" field',
|
|
99
|
+
severity: 'error',
|
|
100
|
+
suggestion: 'Add a human-readable name, e.g., "Service Health Dashboard"',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
else if (typeof dashboard.name !== 'string') {
|
|
104
|
+
errors.push({
|
|
105
|
+
path: 'name',
|
|
106
|
+
message: '"name" must be a string',
|
|
107
|
+
severity: 'error',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Validate optional description
|
|
111
|
+
if (dashboard.description !== undefined && typeof dashboard.description !== 'string') {
|
|
112
|
+
errors.push({
|
|
113
|
+
path: 'description',
|
|
114
|
+
message: '"description" must be a string',
|
|
115
|
+
severity: 'error',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Validate metrics array
|
|
119
|
+
if (!dashboard.metrics) {
|
|
120
|
+
errors.push({
|
|
121
|
+
path: 'metrics',
|
|
122
|
+
message: 'Missing required "metrics" array',
|
|
123
|
+
severity: 'error',
|
|
124
|
+
suggestion: 'Add at least one metric definition',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else if (!Array.isArray(dashboard.metrics)) {
|
|
128
|
+
errors.push({
|
|
129
|
+
path: 'metrics',
|
|
130
|
+
message: '"metrics" must be an array',
|
|
131
|
+
severity: 'error',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
else if (dashboard.metrics.length === 0) {
|
|
135
|
+
warnings.push({
|
|
136
|
+
path: 'metrics',
|
|
137
|
+
message: 'Dashboard has no metrics defined',
|
|
138
|
+
severity: 'warning',
|
|
139
|
+
suggestion: 'Add at least one metric to make the dashboard useful',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// Validate each metric
|
|
144
|
+
const metricIds = new Set();
|
|
145
|
+
dashboard.metrics.forEach((metric, index) => {
|
|
146
|
+
this.validateMetric(metric, index, errors, warnings, context);
|
|
147
|
+
// Check for duplicate metric IDs
|
|
148
|
+
if (metric.id) {
|
|
149
|
+
if (metricIds.has(metric.id)) {
|
|
150
|
+
errors.push({
|
|
151
|
+
path: `metrics[${index}].id`,
|
|
152
|
+
message: `Duplicate metric ID: "${metric.id}"`,
|
|
153
|
+
severity: 'error',
|
|
154
|
+
suggestion: 'Each metric must have a unique ID',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
metricIds.add(metric.id);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// Validate layout
|
|
162
|
+
if (!dashboard.layout) {
|
|
163
|
+
errors.push({
|
|
164
|
+
path: 'layout',
|
|
165
|
+
message: 'Missing required "layout" object',
|
|
166
|
+
severity: 'error',
|
|
167
|
+
suggestion: 'Add layout with columns and rows',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
else if (typeof dashboard.layout !== 'object' || Array.isArray(dashboard.layout)) {
|
|
171
|
+
errors.push({
|
|
172
|
+
path: 'layout',
|
|
173
|
+
message: '"layout" must be an object',
|
|
174
|
+
severity: 'error',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
// Get metric IDs for panel validation
|
|
179
|
+
const metricIds = new Set((dashboard.metrics || [])
|
|
180
|
+
.filter((m) => !!m && typeof m === 'object' && !!m.id)
|
|
181
|
+
.map((m) => m.id));
|
|
182
|
+
this.validateLayout(dashboard.layout, errors, warnings, metricIds);
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
valid: errors.length === 0,
|
|
186
|
+
errors,
|
|
187
|
+
warnings,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Validate a single metric definition
|
|
192
|
+
*/
|
|
193
|
+
validateMetric(metric, index, errors, warnings, context) {
|
|
194
|
+
const metricPath = `metrics[${index}]`;
|
|
195
|
+
if (!metric || typeof metric !== 'object') {
|
|
196
|
+
errors.push({
|
|
197
|
+
path: metricPath,
|
|
198
|
+
message: 'Metric must be an object',
|
|
199
|
+
severity: 'error',
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const m = metric;
|
|
204
|
+
// Required: id
|
|
205
|
+
if (!m.id) {
|
|
206
|
+
errors.push({
|
|
207
|
+
path: `${metricPath}.id`,
|
|
208
|
+
message: 'Metric is missing required "id" field',
|
|
209
|
+
severity: 'error',
|
|
210
|
+
suggestion: `Add unique ID like "metric-${index + 1}"`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
else if (typeof m.id !== 'string') {
|
|
214
|
+
errors.push({
|
|
215
|
+
path: `${metricPath}.id`,
|
|
216
|
+
message: 'Metric "id" must be a string',
|
|
217
|
+
severity: 'error',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
else if (!/^[a-z0-9-]+$/.test(m.id)) {
|
|
221
|
+
warnings.push({
|
|
222
|
+
path: `${metricPath}.id`,
|
|
223
|
+
message: 'Metric "id" should be kebab-case',
|
|
224
|
+
severity: 'warning',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
// Required: name
|
|
228
|
+
if (!m.name) {
|
|
229
|
+
errors.push({
|
|
230
|
+
path: `${metricPath}.name`,
|
|
231
|
+
message: 'Metric is missing required "name" field',
|
|
232
|
+
severity: 'error',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
else if (typeof m.name !== 'string') {
|
|
236
|
+
errors.push({
|
|
237
|
+
path: `${metricPath}.name`,
|
|
238
|
+
message: 'Metric "name" must be a string',
|
|
239
|
+
severity: 'error',
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// Required: type
|
|
243
|
+
if (!m.type) {
|
|
244
|
+
errors.push({
|
|
245
|
+
path: `${metricPath}.type`,
|
|
246
|
+
message: 'Metric is missing required "type" field',
|
|
247
|
+
severity: 'error',
|
|
248
|
+
suggestion: `Valid types: ${VALID_METRIC_TYPES.join(', ')}`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
else if (!VALID_METRIC_TYPES.includes(m.type)) {
|
|
252
|
+
errors.push({
|
|
253
|
+
path: `${metricPath}.type`,
|
|
254
|
+
message: `Invalid metric type: "${m.type}"`,
|
|
255
|
+
severity: 'error',
|
|
256
|
+
suggestion: `Valid types: ${VALID_METRIC_TYPES.join(', ')}`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
// Required: sources
|
|
260
|
+
if (!m.sources) {
|
|
261
|
+
errors.push({
|
|
262
|
+
path: `${metricPath}.sources`,
|
|
263
|
+
message: 'Metric is missing required "sources" array',
|
|
264
|
+
severity: 'error',
|
|
265
|
+
suggestion: 'Add at least one source linking to a storyboard/workflow',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
else if (!Array.isArray(m.sources)) {
|
|
269
|
+
errors.push({
|
|
270
|
+
path: `${metricPath}.sources`,
|
|
271
|
+
message: 'Metric "sources" must be an array',
|
|
272
|
+
severity: 'error',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
else if (m.sources.length === 0) {
|
|
276
|
+
errors.push({
|
|
277
|
+
path: `${metricPath}.sources`,
|
|
278
|
+
message: 'Metric must have at least one source',
|
|
279
|
+
severity: 'error',
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
m.sources.forEach((source, sourceIndex) => {
|
|
284
|
+
this.validateSource(source, `${metricPath}.sources[${sourceIndex}]`, errors, warnings, context);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
// Required: query
|
|
288
|
+
if (!m.query) {
|
|
289
|
+
errors.push({
|
|
290
|
+
path: `${metricPath}.query`,
|
|
291
|
+
message: 'Metric is missing required "query" object',
|
|
292
|
+
severity: 'error',
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
else if (typeof m.query !== 'object' || Array.isArray(m.query)) {
|
|
296
|
+
errors.push({
|
|
297
|
+
path: `${metricPath}.query`,
|
|
298
|
+
message: 'Metric "query" must be an object',
|
|
299
|
+
severity: 'error',
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
this.validateQuery(m.query, `${metricPath}.query`, errors, warnings);
|
|
304
|
+
}
|
|
305
|
+
// Optional: thresholds
|
|
306
|
+
if (m.thresholds !== undefined) {
|
|
307
|
+
if (typeof m.thresholds !== 'object' || Array.isArray(m.thresholds)) {
|
|
308
|
+
errors.push({
|
|
309
|
+
path: `${metricPath}.thresholds`,
|
|
310
|
+
message: '"thresholds" must be an object',
|
|
311
|
+
severity: 'error',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
if (m.thresholds.warning !== undefined && typeof m.thresholds.warning !== 'number') {
|
|
316
|
+
errors.push({
|
|
317
|
+
path: `${metricPath}.thresholds.warning`,
|
|
318
|
+
message: 'Threshold "warning" must be a number',
|
|
319
|
+
severity: 'error',
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
if (m.thresholds.critical !== undefined && typeof m.thresholds.critical !== 'number') {
|
|
323
|
+
errors.push({
|
|
324
|
+
path: `${metricPath}.thresholds.critical`,
|
|
325
|
+
message: 'Threshold "critical" must be a number',
|
|
326
|
+
severity: 'error',
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Optional: display
|
|
332
|
+
if (m.display !== undefined) {
|
|
333
|
+
this.validateDisplay(m.display, `${metricPath}.display`, errors, warnings);
|
|
334
|
+
}
|
|
335
|
+
// Check for _mockData (informational)
|
|
336
|
+
if (!m._mockData) {
|
|
337
|
+
warnings.push({
|
|
338
|
+
path: `${metricPath}._mockData`,
|
|
339
|
+
message: 'No mock data provided for prototyping',
|
|
340
|
+
severity: 'warning',
|
|
341
|
+
suggestion: 'Add _mockData to prototype the dashboard before live OTEL data',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Validate a metric source
|
|
347
|
+
*/
|
|
348
|
+
validateSource(source, path, errors, warnings, context) {
|
|
349
|
+
if (!source || typeof source !== 'object') {
|
|
350
|
+
errors.push({
|
|
351
|
+
path,
|
|
352
|
+
message: 'Source must be an object',
|
|
353
|
+
severity: 'error',
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const s = source;
|
|
358
|
+
// Required: storyboard
|
|
359
|
+
if (!s.storyboard) {
|
|
360
|
+
errors.push({
|
|
361
|
+
path: `${path}.storyboard`,
|
|
362
|
+
message: 'Source is missing required "storyboard" field',
|
|
363
|
+
severity: 'error',
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
else if (typeof s.storyboard !== 'string') {
|
|
367
|
+
errors.push({
|
|
368
|
+
path: `${path}.storyboard`,
|
|
369
|
+
message: 'Source "storyboard" must be a string',
|
|
370
|
+
severity: 'error',
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
else if (context?.storyboards && !context.storyboards.includes(s.storyboard)) {
|
|
374
|
+
warnings.push({
|
|
375
|
+
path: `${path}.storyboard`,
|
|
376
|
+
message: `Unknown storyboard: "${s.storyboard}"`,
|
|
377
|
+
severity: 'warning',
|
|
378
|
+
suggestion: `Known storyboards: ${context.storyboards.join(', ')}`,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
// Required: workflow
|
|
382
|
+
if (!s.workflow) {
|
|
383
|
+
errors.push({
|
|
384
|
+
path: `${path}.workflow`,
|
|
385
|
+
message: 'Source is missing required "workflow" field',
|
|
386
|
+
severity: 'error',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
else if (typeof s.workflow !== 'string') {
|
|
390
|
+
errors.push({
|
|
391
|
+
path: `${path}.workflow`,
|
|
392
|
+
message: 'Source "workflow" must be a string',
|
|
393
|
+
severity: 'error',
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
else if (context?.workflows &&
|
|
397
|
+
s.storyboard &&
|
|
398
|
+
context.workflows[s.storyboard] &&
|
|
399
|
+
!context.workflows[s.storyboard].includes(s.workflow)) {
|
|
400
|
+
warnings.push({
|
|
401
|
+
path: `${path}.workflow`,
|
|
402
|
+
message: `Unknown workflow "${s.workflow}" in storyboard "${s.storyboard}"`,
|
|
403
|
+
severity: 'warning',
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// Optional: type
|
|
407
|
+
if (s.type !== undefined && s.type !== 'event' && s.type !== 'span') {
|
|
408
|
+
errors.push({
|
|
409
|
+
path: `${path}.type`,
|
|
410
|
+
message: 'Source "type" must be "event" or "span"',
|
|
411
|
+
severity: 'error',
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
// Optional: nodes
|
|
415
|
+
if (s.nodes !== undefined) {
|
|
416
|
+
if (!Array.isArray(s.nodes)) {
|
|
417
|
+
errors.push({
|
|
418
|
+
path: `${path}.nodes`,
|
|
419
|
+
message: 'Source "nodes" must be an array of strings',
|
|
420
|
+
severity: 'error',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
s.nodes.forEach((node, i) => {
|
|
425
|
+
if (typeof node !== 'string') {
|
|
426
|
+
errors.push({
|
|
427
|
+
path: `${path}.nodes[${i}]`,
|
|
428
|
+
message: 'Node must be a string',
|
|
429
|
+
severity: 'error',
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Optional: event
|
|
436
|
+
if (s.event !== undefined && typeof s.event !== 'string') {
|
|
437
|
+
errors.push({
|
|
438
|
+
path: `${path}.event`,
|
|
439
|
+
message: 'Source "event" must be a string',
|
|
440
|
+
severity: 'error',
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Validate a metric query
|
|
446
|
+
*/
|
|
447
|
+
validateQuery(query, path, errors, warnings) {
|
|
448
|
+
const q = query;
|
|
449
|
+
// Required: derivation
|
|
450
|
+
if (!q.derivation) {
|
|
451
|
+
errors.push({
|
|
452
|
+
path: `${path}.derivation`,
|
|
453
|
+
message: 'Query is missing required "derivation" field',
|
|
454
|
+
severity: 'error',
|
|
455
|
+
suggestion: `Valid derivations: ${VALID_DERIVATIONS.join(', ')}`,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
else if (!VALID_DERIVATIONS.includes(q.derivation)) {
|
|
459
|
+
errors.push({
|
|
460
|
+
path: `${path}.derivation`,
|
|
461
|
+
message: `Invalid derivation: "${q.derivation}"`,
|
|
462
|
+
severity: 'error',
|
|
463
|
+
suggestion: `Valid derivations: ${VALID_DERIVATIONS.join(', ')}`,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
// Optional: timeGroup
|
|
467
|
+
if (q.timeGroup !== undefined && !VALID_TIME_GROUPS.includes(q.timeGroup)) {
|
|
468
|
+
errors.push({
|
|
469
|
+
path: `${path}.timeGroup`,
|
|
470
|
+
message: `Invalid timeGroup: "${q.timeGroup}"`,
|
|
471
|
+
severity: 'error',
|
|
472
|
+
suggestion: `Valid time groups: ${VALID_TIME_GROUPS.join(', ')}`,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
// Optional: groupBy
|
|
476
|
+
if (q.groupBy !== undefined) {
|
|
477
|
+
if (!Array.isArray(q.groupBy)) {
|
|
478
|
+
errors.push({
|
|
479
|
+
path: `${path}.groupBy`,
|
|
480
|
+
message: '"groupBy" must be an array of strings',
|
|
481
|
+
severity: 'error',
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
q.groupBy.forEach((field, i) => {
|
|
486
|
+
if (typeof field !== 'string') {
|
|
487
|
+
errors.push({
|
|
488
|
+
path: `${path}.groupBy[${i}]`,
|
|
489
|
+
message: 'groupBy field must be a string',
|
|
490
|
+
severity: 'error',
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// Optional: window
|
|
497
|
+
if (q.window !== undefined && typeof q.window !== 'string') {
|
|
498
|
+
errors.push({
|
|
499
|
+
path: `${path}.window`,
|
|
500
|
+
message: '"window" must be a string (e.g., "1h", "24h")',
|
|
501
|
+
severity: 'error',
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Validate metric display options
|
|
507
|
+
*/
|
|
508
|
+
validateDisplay(display, path, errors, warnings) {
|
|
509
|
+
if (typeof display !== 'object' || Array.isArray(display)) {
|
|
510
|
+
errors.push({
|
|
511
|
+
path,
|
|
512
|
+
message: '"display" must be an object',
|
|
513
|
+
severity: 'error',
|
|
514
|
+
});
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const d = display;
|
|
518
|
+
// Optional: component
|
|
519
|
+
if (d.component !== undefined && !VALID_DISPLAY_COMPONENTS.includes(d.component)) {
|
|
520
|
+
errors.push({
|
|
521
|
+
path: `${path}.component`,
|
|
522
|
+
message: `Invalid display component: "${d.component}"`,
|
|
523
|
+
severity: 'error',
|
|
524
|
+
suggestion: `Valid components: ${VALID_DISPLAY_COMPONENTS.join(', ')}`,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
// Optional: size
|
|
528
|
+
if (d.size !== undefined && !['small', 'medium', 'large'].includes(d.size)) {
|
|
529
|
+
errors.push({
|
|
530
|
+
path: `${path}.size`,
|
|
531
|
+
message: `Invalid size: "${d.size}"`,
|
|
532
|
+
severity: 'error',
|
|
533
|
+
suggestion: 'Valid sizes: small, medium, large',
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Validate dashboard layout
|
|
539
|
+
*/
|
|
540
|
+
validateLayout(layout, errors, warnings, metricIds) {
|
|
541
|
+
const l = layout;
|
|
542
|
+
// Optional: columns
|
|
543
|
+
if (l.columns !== undefined) {
|
|
544
|
+
if (typeof l.columns !== 'number' || l.columns < 1) {
|
|
545
|
+
errors.push({
|
|
546
|
+
path: 'layout.columns',
|
|
547
|
+
message: '"columns" must be a positive number',
|
|
548
|
+
severity: 'error',
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// Required: rows
|
|
553
|
+
if (!l.rows) {
|
|
554
|
+
errors.push({
|
|
555
|
+
path: 'layout.rows',
|
|
556
|
+
message: 'Layout is missing required "rows" array',
|
|
557
|
+
severity: 'error',
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
else if (!Array.isArray(l.rows)) {
|
|
561
|
+
errors.push({
|
|
562
|
+
path: 'layout.rows',
|
|
563
|
+
message: '"rows" must be an array',
|
|
564
|
+
severity: 'error',
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
else if (l.rows.length === 0) {
|
|
568
|
+
warnings.push({
|
|
569
|
+
path: 'layout.rows',
|
|
570
|
+
message: 'Layout has no rows defined',
|
|
571
|
+
severity: 'warning',
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
const referencedMetricIds = new Set();
|
|
576
|
+
l.rows.forEach((row, rowIndex) => {
|
|
577
|
+
this.validateRow(row, rowIndex, errors, warnings, metricIds, referencedMetricIds);
|
|
578
|
+
});
|
|
579
|
+
// Check for orphaned metrics (defined but not in layout)
|
|
580
|
+
metricIds.forEach((id) => {
|
|
581
|
+
if (!referencedMetricIds.has(id)) {
|
|
582
|
+
warnings.push({
|
|
583
|
+
path: `layout`,
|
|
584
|
+
message: `Metric "${id}" is defined but not placed in any layout row`,
|
|
585
|
+
severity: 'warning',
|
|
586
|
+
suggestion: 'Add a panel referencing this metric or remove the metric definition',
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Validate a layout row
|
|
594
|
+
*/
|
|
595
|
+
validateRow(row, rowIndex, errors, warnings, metricIds, referencedMetricIds) {
|
|
596
|
+
const rowPath = `layout.rows[${rowIndex}]`;
|
|
597
|
+
if (!row || typeof row !== 'object') {
|
|
598
|
+
errors.push({
|
|
599
|
+
path: rowPath,
|
|
600
|
+
message: 'Row must be an object',
|
|
601
|
+
severity: 'error',
|
|
602
|
+
});
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const r = row;
|
|
606
|
+
// Optional: title
|
|
607
|
+
if (r.title !== undefined && typeof r.title !== 'string') {
|
|
608
|
+
errors.push({
|
|
609
|
+
path: `${rowPath}.title`,
|
|
610
|
+
message: 'Row "title" must be a string',
|
|
611
|
+
severity: 'error',
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
// Required: panels
|
|
615
|
+
if (!r.panels) {
|
|
616
|
+
errors.push({
|
|
617
|
+
path: `${rowPath}.panels`,
|
|
618
|
+
message: 'Row is missing required "panels" array',
|
|
619
|
+
severity: 'error',
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
else if (!Array.isArray(r.panels)) {
|
|
623
|
+
errors.push({
|
|
624
|
+
path: `${rowPath}.panels`,
|
|
625
|
+
message: '"panels" must be an array',
|
|
626
|
+
severity: 'error',
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
else if (r.panels.length === 0) {
|
|
630
|
+
warnings.push({
|
|
631
|
+
path: `${rowPath}.panels`,
|
|
632
|
+
message: 'Row has no panels',
|
|
633
|
+
severity: 'warning',
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
r.panels.forEach((panel, panelIndex) => {
|
|
638
|
+
this.validatePanel(panel, `${rowPath}.panels[${panelIndex}]`, errors, warnings, metricIds, referencedMetricIds);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Validate a panel placement
|
|
644
|
+
*/
|
|
645
|
+
validatePanel(panel, path, errors, warnings, metricIds, referencedMetricIds) {
|
|
646
|
+
if (!panel || typeof panel !== 'object') {
|
|
647
|
+
errors.push({
|
|
648
|
+
path,
|
|
649
|
+
message: 'Panel must be an object',
|
|
650
|
+
severity: 'error',
|
|
651
|
+
});
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const p = panel;
|
|
655
|
+
// Required: id
|
|
656
|
+
if (!p.id) {
|
|
657
|
+
errors.push({
|
|
658
|
+
path: `${path}.id`,
|
|
659
|
+
message: 'Panel is missing required "id" field',
|
|
660
|
+
severity: 'error',
|
|
661
|
+
suggestion: 'Reference a metric ID defined in the metrics array',
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
else if (typeof p.id !== 'string') {
|
|
665
|
+
errors.push({
|
|
666
|
+
path: `${path}.id`,
|
|
667
|
+
message: 'Panel "id" must be a string',
|
|
668
|
+
severity: 'error',
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
referencedMetricIds.add(p.id);
|
|
673
|
+
if (!metricIds.has(p.id)) {
|
|
674
|
+
errors.push({
|
|
675
|
+
path: `${path}.id`,
|
|
676
|
+
message: `Panel references unknown metric: "${p.id}"`,
|
|
677
|
+
severity: 'error',
|
|
678
|
+
suggestion: `Available metrics: ${Array.from(metricIds).join(', ') || '(none defined)'}`,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// Optional: span
|
|
683
|
+
if (p.span !== undefined) {
|
|
684
|
+
if (typeof p.span !== 'number' || p.span < 1) {
|
|
685
|
+
errors.push({
|
|
686
|
+
path: `${path}.span`,
|
|
687
|
+
message: '"span" must be a positive number',
|
|
688
|
+
severity: 'error',
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
// Optional: spanMobile
|
|
693
|
+
if (p.spanMobile !== undefined) {
|
|
694
|
+
if (typeof p.spanMobile !== 'number' || p.spanMobile < 1) {
|
|
695
|
+
errors.push({
|
|
696
|
+
path: `${path}.spanMobile`,
|
|
697
|
+
message: '"spanMobile" must be a positive number',
|
|
698
|
+
severity: 'error',
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
else if (p.span !== undefined) {
|
|
703
|
+
warnings.push({
|
|
704
|
+
path: `${path}`,
|
|
705
|
+
message: 'Panel has span but no spanMobile for responsive layout',
|
|
706
|
+
severity: 'warning',
|
|
707
|
+
suggestion: 'Add spanMobile: 12 for full-width on mobile',
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
// Optional: minHeight
|
|
711
|
+
if (p.minHeight !== undefined && typeof p.minHeight !== 'number') {
|
|
712
|
+
errors.push({
|
|
713
|
+
path: `${path}.minHeight`,
|
|
714
|
+
message: '"minHeight" must be a number',
|
|
715
|
+
severity: 'error',
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Validate and throw if invalid
|
|
721
|
+
*/
|
|
722
|
+
validateOrThrow(data, filePath, context) {
|
|
723
|
+
const result = this.validate(data, filePath, context);
|
|
724
|
+
if (!result.valid) {
|
|
725
|
+
const errorMessages = result.errors.map((e) => `${e.path}: ${e.message}${e.suggestion ? ` (${e.suggestion})` : ''}`);
|
|
726
|
+
throw new Error(`Invalid dashboard data:\n${errorMessages.join('\n')}`);
|
|
727
|
+
}
|
|
728
|
+
return data;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Format validation result as human-readable report
|
|
732
|
+
*/
|
|
733
|
+
formatReport(result) {
|
|
734
|
+
const lines = [];
|
|
735
|
+
if (result.valid && result.warnings.length === 0) {
|
|
736
|
+
lines.push('✓ Dashboard definition is valid');
|
|
737
|
+
return lines.join('\n');
|
|
738
|
+
}
|
|
739
|
+
if (result.errors.length > 0) {
|
|
740
|
+
lines.push('Validation errors:\n');
|
|
741
|
+
result.errors.forEach((error) => {
|
|
742
|
+
lines.push(` ${error.path}`);
|
|
743
|
+
lines.push(` ${error.message}`);
|
|
744
|
+
if (error.suggestion) {
|
|
745
|
+
lines.push(` → ${error.suggestion}`);
|
|
746
|
+
}
|
|
747
|
+
lines.push('');
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
if (result.warnings.length > 0) {
|
|
751
|
+
lines.push('Warnings:\n');
|
|
752
|
+
result.warnings.forEach((warning) => {
|
|
753
|
+
lines.push(` ${warning.path}`);
|
|
754
|
+
lines.push(` ${warning.message}`);
|
|
755
|
+
if (warning.suggestion) {
|
|
756
|
+
lines.push(` → ${warning.suggestion}`);
|
|
757
|
+
}
|
|
758
|
+
lines.push('');
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
return lines.join('\n');
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
exports.DashboardValidator = DashboardValidator;
|
|
765
|
+
/**
|
|
766
|
+
* Create a new dashboard validator instance
|
|
767
|
+
*/
|
|
768
|
+
function createDashboardValidator() {
|
|
769
|
+
return new DashboardValidator();
|
|
770
|
+
}
|
|
771
|
+
exports.createDashboardValidator = createDashboardValidator;
|
|
772
|
+
//# sourceMappingURL=DashboardValidator.js.map
|