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