@r0hitsharma/dashboard-kit 0.12.0-rohit-fork-ci.1
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/README.md +150 -0
- package/dist/charting-interaction.d.ts +47 -0
- package/dist/charting-interaction.js +112 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +10 -0
- package/dist/interaction.d.ts +43 -0
- package/dist/interaction.js +41 -0
- package/dist/registry.d.ts +37 -0
- package/dist/registry.js +220 -0
- package/dist/renderer.d.ts +38 -0
- package/dist/renderer.js +140 -0
- package/dist/schema.d.ts +138 -0
- package/dist/schema.js +25 -0
- package/dist/validate.d.ts +104 -0
- package/dist/validate.js +174 -0
- package/package.json +61 -0
- package/src/charting-interaction.test.ts +95 -0
- package/src/charting-interaction.tsx +199 -0
- package/src/index.ts +57 -0
- package/src/interaction.ts +89 -0
- package/src/registry.tsx +410 -0
- package/src/renderer.tsx +275 -0
- package/src/schema.ts +148 -0
- package/src/validate.test.ts +257 -0
- package/src/validate.ts +233 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@r0hitsharma/dashboard-kit` — the generic, manifest-driven dashboard
|
|
3
|
+
* engine.
|
|
4
|
+
*
|
|
5
|
+
* Three concerns stay SEPARABLE rather than conflated the way Grafana (flat,
|
|
6
|
+
* absolute `gridPos` + string-interpolated query blobs) or Superset (three
|
|
7
|
+
* disconnected config blobs) do:
|
|
8
|
+
*
|
|
9
|
+
* 1. `layout` — a recursive composition tree (Vega-Lite `hconcat`/
|
|
10
|
+
* `vconcat` style), independent of what each leaf renders.
|
|
11
|
+
* 2. `widgets` — a flat, id-keyed registry of widget nodes (`{component,
|
|
12
|
+
* props, dataBinding}`), each resolved through a string -> component
|
|
13
|
+
* registry key (see `registry.tsx`).
|
|
14
|
+
* 3. `dataBinding` — a typed `{source, fields}` pointer at a data source,
|
|
15
|
+
* uniform across whatever transport a consuming app resolves `source`
|
|
16
|
+
* ids against (live tail, replay cursor, a REST poll, ...).
|
|
17
|
+
*
|
|
18
|
+
* The one addition none of Grafana/Superset/Perspective/Vega-Lite model as
|
|
19
|
+
* part of the *widget* node itself is `interaction.reads`/`writes`: the
|
|
20
|
+
* shared-selection keys a widget consumes or produces. Declaring it here
|
|
21
|
+
* makes cross-filtering a declared, auditable property of the manifest
|
|
22
|
+
* instead of implicit wiring buried in component code — see `interaction.ts`
|
|
23
|
+
* for how those keys reach a real interaction store.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type LayoutDirection = 'row' | 'column';
|
|
27
|
+
|
|
28
|
+
/** A recursive split node — maps onto the design-system's `SplitLayout` orientation. */
|
|
29
|
+
export type SplitLayoutNode = {
|
|
30
|
+
type: 'split';
|
|
31
|
+
direction: LayoutDirection;
|
|
32
|
+
/** Relative share of the parent split's main axis; children need not sum to 1. */
|
|
33
|
+
size?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Render this split as a DRAG-RESIZABLE panel group (design-system's
|
|
36
|
+
* `SplitLayout`, itself an Ark `Splitter`) instead of a plain flex box,
|
|
37
|
+
* with one panel per child and a resize handle between each pair. N-way by
|
|
38
|
+
* construction: three children give two handles.
|
|
39
|
+
*
|
|
40
|
+
* Opt-in rather than the default, because a `SplitLayout` needs hard panel
|
|
41
|
+
* sizes and therefore cannot flex-wrap: the non-resizable path keeps the
|
|
42
|
+
* responsive wrapping some dashboards rely on at narrow widths. A
|
|
43
|
+
* `direction: 'column'` split additionally needs {@link height}, since a
|
|
44
|
+
* vertical splitter has no content-driven size to divide.
|
|
45
|
+
*/
|
|
46
|
+
resizable?: boolean;
|
|
47
|
+
/** Required for a resizable `column` split: the CSS height to divide. */
|
|
48
|
+
height?: string;
|
|
49
|
+
children: LayoutNode[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A leaf that resolves to one entry in the flat `widgets` registry. */
|
|
53
|
+
export type WidgetLayoutNode = {
|
|
54
|
+
type: 'widget';
|
|
55
|
+
/** Key into `DashboardSpec['widgets']`. */
|
|
56
|
+
ref: string;
|
|
57
|
+
/** Relative share of the parent split's main axis. */
|
|
58
|
+
size?: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type LayoutNode = SplitLayoutNode | WidgetLayoutNode;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What data source feeds a widget, and how its fields map onto the shape the
|
|
65
|
+
* component needs — a typed channel-to-field mapping (Vega-Lite style)
|
|
66
|
+
* rather than an opaque query string. `source` ids are resolved through
|
|
67
|
+
* whatever {@link DashboardDataSources} table the host app supplies;
|
|
68
|
+
* `dashboard-kit` itself is agnostic to what backs a source (a mock array, a
|
|
69
|
+
* live tail, a replay cursor).
|
|
70
|
+
*/
|
|
71
|
+
export type DataBinding = {
|
|
72
|
+
/** Data-source id, e.g. `"stream:metrics.throughput"`. */
|
|
73
|
+
source: string;
|
|
74
|
+
/** Field name mapping: which field of a source record feeds which slot. */
|
|
75
|
+
fields: Record<string, string>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Shared-selection keys a widget consumes (`reads`) or produces (`writes`).
|
|
80
|
+
* Symmetric by design — any widget may read or write any key, no
|
|
81
|
+
* master/detail hardcoding.
|
|
82
|
+
*/
|
|
83
|
+
export type WidgetInteraction = {
|
|
84
|
+
reads?: string[];
|
|
85
|
+
writes?: string[];
|
|
86
|
+
/**
|
|
87
|
+
* Per-field AGENT EXPOSURE: which of this widget's interaction keys an
|
|
88
|
+
* agent (a drive tool, a command bar) may write. **Default-deny**: a key a
|
|
89
|
+
* human can write through the UI is NOT agent-writable unless it is listed
|
|
90
|
+
* here.
|
|
91
|
+
*
|
|
92
|
+
* This is the manifest's half of the contract; a host app's agent-facing
|
|
93
|
+
* tools consult {@link collectAgentWritableKeys} and refuse anything
|
|
94
|
+
* outside it, so the agent surface is a declared, auditable property of
|
|
95
|
+
* the dashboard rather than "whatever setters happen to be in scope".
|
|
96
|
+
*/
|
|
97
|
+
agentWritable?: string[];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type ThresholdSeverity = 'success' | 'warning' | 'critical';
|
|
101
|
+
|
|
102
|
+
export type ThresholdRule = {
|
|
103
|
+
op: 'gte' | 'lte' | 'gt' | 'lt' | 'eq';
|
|
104
|
+
value: number;
|
|
105
|
+
severity: ThresholdSeverity;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** One declarative column for a table-rendering widget. */
|
|
109
|
+
export type WidgetTableColumn = {
|
|
110
|
+
accessorKey: string;
|
|
111
|
+
header: string;
|
|
112
|
+
/** How the registry's table adapter should render the cell. */
|
|
113
|
+
render?: 'text' | 'number' | 'currency' | 'percent' | 'badge' | 'sparkline';
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export type WidgetNode = {
|
|
117
|
+
id: string;
|
|
118
|
+
/** Component-registry key, e.g. `"panel.stat"` or `"chart.line"`. */
|
|
119
|
+
component: string;
|
|
120
|
+
title?: string;
|
|
121
|
+
/**
|
|
122
|
+
* Component-specific configuration, passed through to the resolved
|
|
123
|
+
* component-registry entry. Kept to JSON-serializable shapes (no
|
|
124
|
+
* functions) so a manifest stays a genuine, inspectable data value —
|
|
125
|
+
* `columns` for table widgets is the one structured exception.
|
|
126
|
+
*/
|
|
127
|
+
props?: Record<string, unknown> & { columns?: WidgetTableColumn[] };
|
|
128
|
+
dataBinding?: DataBinding;
|
|
129
|
+
interaction?: WidgetInteraction;
|
|
130
|
+
thresholds?: ThresholdRule[];
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export type DashboardSpec = {
|
|
134
|
+
version: 1;
|
|
135
|
+
title?: string;
|
|
136
|
+
layout: LayoutNode;
|
|
137
|
+
/** Flat registry of widget nodes, referenced by id from `layout`. */
|
|
138
|
+
widgets: Record<string, WidgetNode>;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Data-source table keyed by `DataBinding['source']` — every source resolves
|
|
143
|
+
* to an array of plain records; scalar widgets (e.g. a stat tile reading a
|
|
144
|
+
* gauge) read the last record. A host app owns populating this (from a mock
|
|
145
|
+
* fixture, a live subscription snapshot, a query result, ...); the engine
|
|
146
|
+
* only ever reads it.
|
|
147
|
+
*/
|
|
148
|
+
export type DashboardDataSources = Record<string, Record<string, unknown>[]>;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { DashboardSpec, WidgetNode } from './schema.js';
|
|
4
|
+
import { collectAgentWritableKeys, validateDashboardSpec } from './validate.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A minimal, structurally valid spec: one widget placed by a single-widget
|
|
8
|
+
* layout. Individual tests clone and mutate it to isolate one failure at a
|
|
9
|
+
* time. Pure logic only — nothing here renders a component (DOM-free, matching
|
|
10
|
+
* the repo's node-environment test style).
|
|
11
|
+
*/
|
|
12
|
+
// The `widgets.a` intersection records what the fixture guarantees: `widgets`
|
|
13
|
+
// stays an open `Record` (tests add keys like `orphan` to it), while `a` is a
|
|
14
|
+
// known `WidgetNode` rather than the `| undefined` that
|
|
15
|
+
// `noUncheckedIndexedAccess` gives every other lookup.
|
|
16
|
+
function baseSpec(): DashboardSpec & { widgets: { a: WidgetNode } } {
|
|
17
|
+
return {
|
|
18
|
+
version: 1,
|
|
19
|
+
title: 'Sample',
|
|
20
|
+
layout: { type: 'widget', ref: 'a' },
|
|
21
|
+
widgets: {
|
|
22
|
+
a: { id: 'a', component: 'note', props: { text: 'hello' } },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('validateDashboardSpec — structural', () => {
|
|
28
|
+
it('accepts a minimal valid spec', () => {
|
|
29
|
+
const result = validateDashboardSpec(baseSpec());
|
|
30
|
+
expect(result.ok).toBe(true);
|
|
31
|
+
if (result.ok) expect(result.issues).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('accepts a nested split layout', () => {
|
|
35
|
+
const spec: DashboardSpec = {
|
|
36
|
+
version: 1,
|
|
37
|
+
layout: {
|
|
38
|
+
type: 'split',
|
|
39
|
+
direction: 'row',
|
|
40
|
+
children: [
|
|
41
|
+
{ type: 'widget', ref: 'a' },
|
|
42
|
+
{
|
|
43
|
+
type: 'split',
|
|
44
|
+
direction: 'column',
|
|
45
|
+
children: [{ type: 'widget', ref: 'b' }],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
widgets: {
|
|
50
|
+
a: { id: 'a', component: 'note' },
|
|
51
|
+
b: { id: 'b', component: 'stat' },
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
expect(validateDashboardSpec(spec).ok).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects a wrong version literal', () => {
|
|
58
|
+
const spec = { ...baseSpec(), version: 2 } as unknown;
|
|
59
|
+
const result = validateDashboardSpec(spec);
|
|
60
|
+
expect(result.ok).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects a malformed layout node (missing type)', () => {
|
|
64
|
+
const spec = {
|
|
65
|
+
version: 1,
|
|
66
|
+
layout: { ref: 'a' },
|
|
67
|
+
widgets: { a: { id: 'a', component: 'note' } },
|
|
68
|
+
} as unknown;
|
|
69
|
+
expect(validateDashboardSpec(spec).ok).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects a bad threshold enum', () => {
|
|
73
|
+
const spec = baseSpec();
|
|
74
|
+
spec.widgets.a.thresholds = [
|
|
75
|
+
// @ts-expect-error — intentionally invalid op for the test
|
|
76
|
+
{ op: 'between', value: 1, severity: 'warning' },
|
|
77
|
+
];
|
|
78
|
+
expect(validateDashboardSpec(spec).ok).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('validateDashboardSpec — referential', () => {
|
|
83
|
+
it('flags a layout ref with no matching widget', () => {
|
|
84
|
+
const spec = baseSpec();
|
|
85
|
+
spec.layout = { type: 'widget', ref: 'missing' };
|
|
86
|
+
const result = validateDashboardSpec(spec);
|
|
87
|
+
expect(result.ok).toBe(false);
|
|
88
|
+
if (!result.ok) {
|
|
89
|
+
expect(result.issues.some((i) => i.path === 'layout.ref')).toBe(true);
|
|
90
|
+
expect(result.issues[0]?.message).toContain('unknown widget');
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('flags an orphaned widget never placed by the layout', () => {
|
|
95
|
+
const spec = baseSpec();
|
|
96
|
+
spec.widgets.orphan = { id: 'orphan', component: 'note' };
|
|
97
|
+
const result = validateDashboardSpec(spec);
|
|
98
|
+
expect(result.ok).toBe(false);
|
|
99
|
+
if (!result.ok) {
|
|
100
|
+
expect(result.issues.some((i) => i.path === 'widgets.orphan')).toBe(true);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('flags a widget whose id disagrees with its registry key', () => {
|
|
105
|
+
const spec = baseSpec();
|
|
106
|
+
spec.widgets.a.id = 'mismatch';
|
|
107
|
+
const result = validateDashboardSpec(spec);
|
|
108
|
+
expect(result.ok).toBe(false);
|
|
109
|
+
if (!result.ok) {
|
|
110
|
+
expect(result.issues.some((i) => i.path === 'widgets.a.id')).toBe(true);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('flags a resizable column split with no height', () => {
|
|
115
|
+
const spec: DashboardSpec = {
|
|
116
|
+
version: 1,
|
|
117
|
+
layout: {
|
|
118
|
+
type: 'split',
|
|
119
|
+
direction: 'column',
|
|
120
|
+
resizable: true,
|
|
121
|
+
children: [{ type: 'widget', ref: 'a' }],
|
|
122
|
+
},
|
|
123
|
+
widgets: { a: { id: 'a', component: 'note' } },
|
|
124
|
+
};
|
|
125
|
+
const result = validateDashboardSpec(spec);
|
|
126
|
+
expect(result.ok).toBe(false);
|
|
127
|
+
if (!result.ok) {
|
|
128
|
+
expect(result.issues.some((i) => i.path.endsWith('.height'))).toBe(true);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('accepts a resizable column split that declares a height', () => {
|
|
133
|
+
const spec: DashboardSpec = {
|
|
134
|
+
version: 1,
|
|
135
|
+
layout: {
|
|
136
|
+
type: 'split',
|
|
137
|
+
direction: 'column',
|
|
138
|
+
resizable: true,
|
|
139
|
+
height: '400px',
|
|
140
|
+
children: [{ type: 'widget', ref: 'a' }],
|
|
141
|
+
},
|
|
142
|
+
widgets: { a: { id: 'a', component: 'note' } },
|
|
143
|
+
};
|
|
144
|
+
expect(validateDashboardSpec(spec).ok).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('accepts a resizable ROW split without a height (row divides width)', () => {
|
|
148
|
+
const spec: DashboardSpec = {
|
|
149
|
+
version: 1,
|
|
150
|
+
layout: {
|
|
151
|
+
type: 'split',
|
|
152
|
+
direction: 'row',
|
|
153
|
+
resizable: true,
|
|
154
|
+
children: [
|
|
155
|
+
{ type: 'widget', ref: 'a' },
|
|
156
|
+
{ type: 'widget', ref: 'b' },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
widgets: {
|
|
160
|
+
a: { id: 'a', component: 'note' },
|
|
161
|
+
b: { id: 'b', component: 'stat' },
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
expect(validateDashboardSpec(spec).ok).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('validateDashboardSpec — agentWritable', () => {
|
|
169
|
+
it('flags an agentWritable key not present in writes', () => {
|
|
170
|
+
const spec = baseSpec();
|
|
171
|
+
spec.widgets.a.interaction = {
|
|
172
|
+
writes: ['highlightedKey'],
|
|
173
|
+
agentWritable: ['selectedTimeRange'],
|
|
174
|
+
};
|
|
175
|
+
const result = validateDashboardSpec(spec);
|
|
176
|
+
expect(result.ok).toBe(false);
|
|
177
|
+
if (!result.ok) {
|
|
178
|
+
expect(
|
|
179
|
+
result.issues.some(
|
|
180
|
+
(i) => i.path === 'widgets.a.interaction.agentWritable',
|
|
181
|
+
),
|
|
182
|
+
).toBe(true);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('accepts an agentWritable key that is also written', () => {
|
|
187
|
+
const spec = baseSpec();
|
|
188
|
+
spec.widgets.a.interaction = {
|
|
189
|
+
writes: ['highlightedKey'],
|
|
190
|
+
agentWritable: ['highlightedKey'],
|
|
191
|
+
};
|
|
192
|
+
expect(validateDashboardSpec(spec).ok).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('validateDashboardSpec — knownComponents', () => {
|
|
197
|
+
it('flags a component key outside the provided allow-list', () => {
|
|
198
|
+
const spec = baseSpec();
|
|
199
|
+
const result = validateDashboardSpec(spec, {
|
|
200
|
+
knownComponents: ['stat', 'table'],
|
|
201
|
+
});
|
|
202
|
+
expect(result.ok).toBe(false);
|
|
203
|
+
if (!result.ok) {
|
|
204
|
+
expect(result.issues.some((i) => i.path === 'widgets.a.component')).toBe(
|
|
205
|
+
true,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('passes when every component key is known', () => {
|
|
211
|
+
const spec = baseSpec();
|
|
212
|
+
expect(validateDashboardSpec(spec, { knownComponents: ['note'] }).ok).toBe(
|
|
213
|
+
true,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('collectAgentWritableKeys', () => {
|
|
219
|
+
it('returns an empty set by default (default-deny)', () => {
|
|
220
|
+
expect(collectAgentWritableKeys(baseSpec()).size).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('unions agentWritable keys across every widget', () => {
|
|
224
|
+
const spec: DashboardSpec = {
|
|
225
|
+
version: 1,
|
|
226
|
+
layout: {
|
|
227
|
+
type: 'split',
|
|
228
|
+
direction: 'row',
|
|
229
|
+
children: [
|
|
230
|
+
{ type: 'widget', ref: 'a' },
|
|
231
|
+
{ type: 'widget', ref: 'b' },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
widgets: {
|
|
235
|
+
a: {
|
|
236
|
+
id: 'a',
|
|
237
|
+
component: 'table',
|
|
238
|
+
interaction: {
|
|
239
|
+
writes: ['highlightedKey'],
|
|
240
|
+
agentWritable: ['highlightedKey'],
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
b: {
|
|
244
|
+
id: 'b',
|
|
245
|
+
component: 'note',
|
|
246
|
+
interaction: {
|
|
247
|
+
writes: ['selectedTimeRange'],
|
|
248
|
+
agentWritable: ['selectedTimeRange'],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
expect(collectAgentWritableKeys(spec)).toEqual(
|
|
254
|
+
new Set(['highlightedKey', 'selectedTimeRange']),
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
});
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest validation — the gate that must land before any "an agent or
|
|
3
|
+
* command bar submits a manifest patch" feature can trust a `DashboardSpec`.
|
|
4
|
+
*
|
|
5
|
+
* Two layers, because zod alone can't catch the interesting failures:
|
|
6
|
+
*
|
|
7
|
+
* 1. STRUCTURAL — `dashboardSpecSchema` below. Shape, enums, required
|
|
8
|
+
* fields, no malformed widget nodes.
|
|
9
|
+
* 2. REFERENTIAL — {@link validateDashboardSpec}'s extra passes. Every
|
|
10
|
+
* `layout` leaf must name a widget that exists; every widget must be
|
|
11
|
+
* reachable from the layout; a resizable `column` split must carry a
|
|
12
|
+
* `height`; an `agentWritable` key must actually be writable. These are
|
|
13
|
+
* the mistakes an agent-authored patch actually makes, and none of them
|
|
14
|
+
* are expressible as a type.
|
|
15
|
+
*
|
|
16
|
+
* `zod` deliberately does NOT validate `component` against a registry here:
|
|
17
|
+
* `schema.ts` is the data contract and a registry is one possible resolution
|
|
18
|
+
* of it. The renderer degrades gracefully on an unknown component key (it
|
|
19
|
+
* renders an `UnknownWidget` marker), and coupling the two would make the
|
|
20
|
+
* schema un-shareable. {@link validateDashboardSpec} takes an optional set of
|
|
21
|
+
* known component keys for callers who DO want that checked.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
|
|
26
|
+
import type { DashboardSpec } from './schema.js';
|
|
27
|
+
|
|
28
|
+
const layoutDirection = z.enum(['row', 'column']);
|
|
29
|
+
|
|
30
|
+
const widgetLayoutNode = z.object({
|
|
31
|
+
type: z.literal('widget'),
|
|
32
|
+
ref: z.string().min(1),
|
|
33
|
+
size: z.number().positive().optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Recursive by `z.lazy` — a split's children are layout nodes, which may be
|
|
38
|
+
* splits.
|
|
39
|
+
*/
|
|
40
|
+
const layoutNode: z.ZodType<unknown> = z.lazy(() =>
|
|
41
|
+
z.union([widgetLayoutNode, splitLayoutNode]),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const splitLayoutNode = z.object({
|
|
45
|
+
type: z.literal('split'),
|
|
46
|
+
direction: layoutDirection,
|
|
47
|
+
size: z.number().positive().optional(),
|
|
48
|
+
resizable: z.boolean().optional(),
|
|
49
|
+
height: z.string().min(1).optional(),
|
|
50
|
+
children: z.array(layoutNode).min(1),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const dataBinding = z.object({
|
|
54
|
+
source: z.string().min(1),
|
|
55
|
+
fields: z.record(z.string(), z.string()),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const widgetInteraction = z.object({
|
|
59
|
+
reads: z.array(z.string().min(1)).optional(),
|
|
60
|
+
writes: z.array(z.string().min(1)).optional(),
|
|
61
|
+
agentWritable: z.array(z.string().min(1)).optional(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const thresholdRule = z.object({
|
|
65
|
+
op: z.enum(['gte', 'lte', 'gt', 'lt', 'eq']),
|
|
66
|
+
value: z.number(),
|
|
67
|
+
severity: z.enum(['success', 'warning', 'critical']),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const widgetTableColumn = z.object({
|
|
71
|
+
accessorKey: z.string().min(1),
|
|
72
|
+
header: z.string(),
|
|
73
|
+
render: z
|
|
74
|
+
.enum(['text', 'number', 'currency', 'percent', 'badge', 'sparkline'])
|
|
75
|
+
.optional(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const widgetNode = z.object({
|
|
79
|
+
id: z.string().min(1),
|
|
80
|
+
component: z.string().min(1),
|
|
81
|
+
title: z.string().optional(),
|
|
82
|
+
// `props` stays open (it's per-component config) EXCEPT `columns`, which is
|
|
83
|
+
// structured enough — and mistyped often enough — to be worth checking.
|
|
84
|
+
props: z
|
|
85
|
+
.looseObject({ columns: z.array(widgetTableColumn).optional() })
|
|
86
|
+
.optional(),
|
|
87
|
+
dataBinding: dataBinding.optional(),
|
|
88
|
+
interaction: widgetInteraction.optional(),
|
|
89
|
+
thresholds: z.array(thresholdRule).optional(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const dashboardSpecSchema = z.object({
|
|
93
|
+
version: z.literal(1),
|
|
94
|
+
title: z.string().optional(),
|
|
95
|
+
layout: layoutNode,
|
|
96
|
+
widgets: z.record(z.string(), widgetNode),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export type ManifestIssue = {
|
|
100
|
+
/** Dotted path into the spec, e.g. `widgets.exposure.component`. */
|
|
101
|
+
path: string;
|
|
102
|
+
message: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type ManifestValidation =
|
|
106
|
+
| { ok: true; spec: DashboardSpec; issues: [] }
|
|
107
|
+
| { ok: false; issues: ManifestIssue[] };
|
|
108
|
+
|
|
109
|
+
type RawLayoutNode = {
|
|
110
|
+
type?: string;
|
|
111
|
+
ref?: string;
|
|
112
|
+
direction?: string;
|
|
113
|
+
resizable?: boolean;
|
|
114
|
+
height?: string;
|
|
115
|
+
children?: RawLayoutNode[];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function walkLayout(
|
|
119
|
+
node: RawLayoutNode,
|
|
120
|
+
path: string,
|
|
121
|
+
visit: (node: RawLayoutNode, path: string) => void,
|
|
122
|
+
): void {
|
|
123
|
+
visit(node, path);
|
|
124
|
+
node.children?.forEach((child, index) =>
|
|
125
|
+
walkLayout(child, `${path}.children[${index}]`, visit),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type ValidateOptions = {
|
|
130
|
+
/**
|
|
131
|
+
* When supplied, every widget's `component` must be one of these keys.
|
|
132
|
+
* Pass `Object.keys(registry)` to catch a typo'd or hallucinated component
|
|
133
|
+
* name before it renders as an `UnknownWidget` placeholder.
|
|
134
|
+
*/
|
|
135
|
+
knownComponents?: Iterable<string>;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Validates a candidate manifest. Never throws — an agent-submitted patch is
|
|
140
|
+
* untrusted input, and the caller needs the reasons, not a stack trace.
|
|
141
|
+
*/
|
|
142
|
+
export function validateDashboardSpec(
|
|
143
|
+
candidate: unknown,
|
|
144
|
+
{ knownComponents }: ValidateOptions = {},
|
|
145
|
+
): ManifestValidation {
|
|
146
|
+
const parsed = dashboardSpecSchema.safeParse(candidate);
|
|
147
|
+
if (!parsed.success) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
issues: parsed.error.issues.map((issue) => ({
|
|
151
|
+
path: issue.path.join('.') || '(root)',
|
|
152
|
+
message: issue.message,
|
|
153
|
+
})),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const spec = parsed.data as unknown as DashboardSpec;
|
|
158
|
+
const issues: ManifestIssue[] = [];
|
|
159
|
+
const known = knownComponents ? new Set(knownComponents) : null;
|
|
160
|
+
|
|
161
|
+
// ── Referential pass ────────────────────────────────────────────────────
|
|
162
|
+
const referenced = new Set<string>();
|
|
163
|
+
walkLayout(spec.layout as RawLayoutNode, 'layout', (node, path) => {
|
|
164
|
+
if (node.type === 'widget') {
|
|
165
|
+
const ref = node.ref as string;
|
|
166
|
+
referenced.add(ref);
|
|
167
|
+
if (!(ref in spec.widgets)) {
|
|
168
|
+
issues.push({
|
|
169
|
+
path: `${path}.ref`,
|
|
170
|
+
message: `layout references unknown widget "${ref}" — add it to \`widgets\` or fix the ref`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (node.resizable && node.direction === 'column' && !node.height) {
|
|
176
|
+
issues.push({
|
|
177
|
+
path: `${path}.height`,
|
|
178
|
+
message:
|
|
179
|
+
'a resizable `column` split needs an explicit `height` — a vertical splitter has no content-driven size to divide',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
for (const [key, widget] of Object.entries(spec.widgets)) {
|
|
185
|
+
if (widget.id !== key) {
|
|
186
|
+
issues.push({
|
|
187
|
+
path: `widgets.${key}.id`,
|
|
188
|
+
message: `widget id "${widget.id}" does not match its registry key "${key}"`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (!referenced.has(key)) {
|
|
192
|
+
issues.push({
|
|
193
|
+
path: `widgets.${key}`,
|
|
194
|
+
message: `widget "${key}" is never placed by \`layout\` — it will not render`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (known && !known.has(widget.component)) {
|
|
198
|
+
issues.push({
|
|
199
|
+
path: `widgets.${key}.component`,
|
|
200
|
+
message: `unknown component "${widget.component}"`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// An agent-writable key that the widget cannot actually write is a policy
|
|
204
|
+
// that silently does nothing — worth catching at the gate. `writes` is the
|
|
205
|
+
// set of keys a widget produces; an `agentWritable` key must be one of them.
|
|
206
|
+
const writes = widget.interaction?.writes ?? [];
|
|
207
|
+
for (const exposed of widget.interaction?.agentWritable ?? []) {
|
|
208
|
+
if (!writes.includes(exposed)) {
|
|
209
|
+
issues.push({
|
|
210
|
+
path: `widgets.${key}.interaction.agentWritable`,
|
|
211
|
+
message: `"${exposed}" is marked agent-writable but is not in this widget's \`writes\``,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return issues.length > 0
|
|
218
|
+
? { ok: false, issues }
|
|
219
|
+
: { ok: true, spec, issues: [] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The set of interaction keys an agent is allowed to write, collected across
|
|
224
|
+
* every widget in the manifest. Default-deny: an empty set means an agent may
|
|
225
|
+
* read the dashboard but drive nothing.
|
|
226
|
+
*/
|
|
227
|
+
export function collectAgentWritableKeys(spec: DashboardSpec): Set<string> {
|
|
228
|
+
const keys = new Set<string>();
|
|
229
|
+
for (const widget of Object.values(spec.widgets)) {
|
|
230
|
+
for (const key of widget.interaction?.agentWritable ?? []) keys.add(key);
|
|
231
|
+
}
|
|
232
|
+
return keys;
|
|
233
|
+
}
|