@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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type JSX } from 'react';
|
|
2
|
+
import { type InteractionContextValue } from './interaction.js';
|
|
3
|
+
import { type ComponentRegistry } from './registry.js';
|
|
4
|
+
import type { DashboardSpec, DashboardDataSources, LayoutNode } from './schema.js';
|
|
5
|
+
type RenderContext = {
|
|
6
|
+
widgets: DashboardSpec['widgets'];
|
|
7
|
+
dataSources: DashboardDataSources;
|
|
8
|
+
registry: ComponentRegistry;
|
|
9
|
+
interaction: InteractionContextValue;
|
|
10
|
+
};
|
|
11
|
+
export declare function renderLayoutNode(node: LayoutNode, ctx: RenderContext, key: string): JSX.Element;
|
|
12
|
+
export type DashboardRendererProps = {
|
|
13
|
+
spec: DashboardSpec;
|
|
14
|
+
dataSources: DashboardDataSources;
|
|
15
|
+
/**
|
|
16
|
+
* The string -> component registry. Defaults to {@link DEFAULT_REGISTRY};
|
|
17
|
+
* pass a merged registry (see `mergeRegistries`) to add domain adapters.
|
|
18
|
+
*/
|
|
19
|
+
registry?: ComponentRegistry;
|
|
20
|
+
/**
|
|
21
|
+
* A real interaction store (e.g. charting-backed via
|
|
22
|
+
* `useChartingInteraction`). Omit to use a self-contained local store, which
|
|
23
|
+
* every standalone manifest (a story, a preview) can rely on unchanged.
|
|
24
|
+
*/
|
|
25
|
+
interaction?: InteractionContextValue;
|
|
26
|
+
/**
|
|
27
|
+
* Skip the zod gate. Only for a spec the caller authored and trusts (where
|
|
28
|
+
* validation is a dev-time assertion, not a trust boundary); leave it on for
|
|
29
|
+
* any spec an agent or a command bar produced.
|
|
30
|
+
*/
|
|
31
|
+
skipValidation?: boolean;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Renders a `DashboardSpec`. Validates first (unless `skipValidation`), showing
|
|
35
|
+
* an annotated rejection for an invalid manifest rather than throwing.
|
|
36
|
+
*/
|
|
37
|
+
export declare function DashboardRenderer({ spec, dataSources, registry, interaction, skipValidation, }: DashboardRendererProps): JSX.Element;
|
|
38
|
+
export {};
|
package/dist/renderer.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Panel, SplitLayout } from '@r0hitsharma/design-system';
|
|
3
|
+
import {} from 'react';
|
|
4
|
+
import { useLocalInteraction, } from './interaction.js';
|
|
5
|
+
import { DEFAULT_REGISTRY, UnknownWidget, } from './registry.js';
|
|
6
|
+
import { validateDashboardSpec } from './validate.js';
|
|
7
|
+
/**
|
|
8
|
+
* The recursive `DashboardSpec` -> JSX renderer. Walks `spec.layout`
|
|
9
|
+
* (positions/nesting only — see schema.ts for why layout stays separate from
|
|
10
|
+
* widget config and data binding), resolving each `widget` leaf through
|
|
11
|
+
* `spec.widgets` and then through the string -> component `registry`. An
|
|
12
|
+
* unresolved widget ref or component key renders an inline `UnknownWidget`
|
|
13
|
+
* marker instead of throwing, so one bad manifest entry doesn't blank the
|
|
14
|
+
* whole dashboard.
|
|
15
|
+
*
|
|
16
|
+
* A `split` node with `resizable: true` becomes a design-system `SplitLayout`
|
|
17
|
+
* (an Ark `Splitter`), one drag-resizable panel per child; otherwise it's a
|
|
18
|
+
* plain flex box that wraps at narrow widths. Nesting works either way — each
|
|
19
|
+
* panel's content is just another recursive render.
|
|
20
|
+
*/
|
|
21
|
+
/** Default height for a resizable split that declares none (a `column` split must; validation enforces it). */
|
|
22
|
+
const DEFAULT_RESIZABLE_HEIGHT = '420px';
|
|
23
|
+
const rootStyle = {
|
|
24
|
+
width: '100%',
|
|
25
|
+
minWidth: 0,
|
|
26
|
+
display: 'flex',
|
|
27
|
+
flexDirection: 'column',
|
|
28
|
+
gap: '1rem',
|
|
29
|
+
};
|
|
30
|
+
const titleStyle = {
|
|
31
|
+
fontSize: '1.125rem',
|
|
32
|
+
fontWeight: 600,
|
|
33
|
+
color: 'var(--colors-text-default, currentColor)',
|
|
34
|
+
margin: 0,
|
|
35
|
+
};
|
|
36
|
+
const invalidStyle = {
|
|
37
|
+
display: 'flex',
|
|
38
|
+
flexDirection: 'column',
|
|
39
|
+
gap: '0.5rem',
|
|
40
|
+
padding: '1rem',
|
|
41
|
+
borderRadius: '0.5rem',
|
|
42
|
+
borderWidth: '1px',
|
|
43
|
+
borderStyle: 'solid',
|
|
44
|
+
borderColor: 'var(--colors-border-critical, currentColor)',
|
|
45
|
+
background: 'var(--colors-bg-critical, transparent)',
|
|
46
|
+
color: 'var(--colors-text-critical, currentColor)',
|
|
47
|
+
fontSize: '0.875rem',
|
|
48
|
+
};
|
|
49
|
+
const issueListStyle = {
|
|
50
|
+
display: 'flex',
|
|
51
|
+
flexDirection: 'column',
|
|
52
|
+
gap: '0.25rem',
|
|
53
|
+
fontFamily: 'var(--fonts-mono, monospace)',
|
|
54
|
+
fontSize: '0.75rem',
|
|
55
|
+
listStyle: 'none',
|
|
56
|
+
padding: 0,
|
|
57
|
+
margin: 0,
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* What an invalid manifest renders instead of a dashboard: an annotated list,
|
|
61
|
+
* not a thrown error — the whole point of the gate is that an agent-submitted
|
|
62
|
+
* patch produces a readable rejection a human (or the agent) can act on.
|
|
63
|
+
*/
|
|
64
|
+
function InvalidSpec({ issues }) {
|
|
65
|
+
return (_jsxs("div", { style: invalidStyle, role: "alert", children: [_jsxs("strong", { children: ["This dashboard manifest is invalid (", issues.length, " issue", issues.length === 1 ? '' : 's', ") and was not rendered."] }), _jsx("ul", { style: issueListStyle, children: issues.map((issue) => (_jsxs("li", { children: [_jsx("code", { children: issue.path }), " \u2014 ", issue.message] }, `${issue.path}:${issue.message}`))) })] }));
|
|
66
|
+
}
|
|
67
|
+
function renderWidget(ref, ctx) {
|
|
68
|
+
const widget = ctx.widgets[ref];
|
|
69
|
+
if (!widget) {
|
|
70
|
+
return (_jsx(UnknownWidget, { label: `layout references unknown widget "${ref}"` }));
|
|
71
|
+
}
|
|
72
|
+
const Adapter = ctx.registry[widget.component];
|
|
73
|
+
if (!Adapter) {
|
|
74
|
+
return _jsx(UnknownWidget, { label: widget.component });
|
|
75
|
+
}
|
|
76
|
+
const data = widget.dataBinding
|
|
77
|
+
? (ctx.dataSources[widget.dataBinding.source] ?? [])
|
|
78
|
+
: [];
|
|
79
|
+
return (_jsx(Panel, { title: widget.title, style: { width: '100%', minWidth: 0 }, "data-widget-id": widget.id, "data-widget-component": widget.component, children: _jsx(Adapter, { widget: widget, data: data, interaction: ctx.interaction }) }));
|
|
80
|
+
}
|
|
81
|
+
function splitContainerStyle(direction) {
|
|
82
|
+
return {
|
|
83
|
+
display: 'flex',
|
|
84
|
+
flexDirection: direction,
|
|
85
|
+
flexWrap: direction === 'row' ? 'wrap' : 'nowrap',
|
|
86
|
+
gap: '1rem',
|
|
87
|
+
width: '100%',
|
|
88
|
+
minWidth: 0,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function ResizableSplit({ node, ctx, nodeKey, }) {
|
|
92
|
+
const panels = node.children.map((child, index) => ({
|
|
93
|
+
id: `${nodeKey}.${index}`,
|
|
94
|
+
size: child.size ?? 1,
|
|
95
|
+
content: renderLayoutNode(child, ctx, `${nodeKey}.${index}`),
|
|
96
|
+
}));
|
|
97
|
+
const height = node.height ?? DEFAULT_RESIZABLE_HEIGHT;
|
|
98
|
+
return (_jsx("div", { style: { width: '100%', minWidth: 0, height }, children: _jsx(SplitLayout, { orientation: node.direction === 'row' ? 'horizontal' : 'vertical', panels: panels }) }));
|
|
99
|
+
}
|
|
100
|
+
export function renderLayoutNode(node, ctx, key) {
|
|
101
|
+
if (node.type === 'widget') {
|
|
102
|
+
return (_jsx("div", { style: {
|
|
103
|
+
display: 'flex',
|
|
104
|
+
flexDirection: 'column',
|
|
105
|
+
minWidth: '280px',
|
|
106
|
+
flexGrow: node.size ?? 1,
|
|
107
|
+
flexBasis: 0,
|
|
108
|
+
}, children: renderWidget(node.ref, ctx) }, key));
|
|
109
|
+
}
|
|
110
|
+
const inner = node.resizable ? (_jsx(ResizableSplit, { node: node, ctx: ctx, nodeKey: key })) : (_jsx("div", { style: splitContainerStyle(node.direction), children: node.children.map((child, index) => renderLayoutNode(child, ctx, `${key}.${index}`)) }));
|
|
111
|
+
return (_jsx("div", { style: {
|
|
112
|
+
display: 'flex',
|
|
113
|
+
flexDirection: 'column',
|
|
114
|
+
minWidth: 0,
|
|
115
|
+
flexGrow: node.size ?? 1,
|
|
116
|
+
flexBasis: 0,
|
|
117
|
+
}, children: inner }, key));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Renders a `DashboardSpec`. Validates first (unless `skipValidation`), showing
|
|
121
|
+
* an annotated rejection for an invalid manifest rather than throwing.
|
|
122
|
+
*/
|
|
123
|
+
export function DashboardRenderer({ spec, dataSources, registry = DEFAULT_REGISTRY, interaction, skipValidation = false, }) {
|
|
124
|
+
const localInteraction = useLocalInteraction();
|
|
125
|
+
const resolvedInteraction = interaction ?? localInteraction;
|
|
126
|
+
if (!skipValidation) {
|
|
127
|
+
const result = validateDashboardSpec(spec, {
|
|
128
|
+
knownComponents: Object.keys(registry),
|
|
129
|
+
});
|
|
130
|
+
if (!result.ok)
|
|
131
|
+
return _jsx(InvalidSpec, { issues: result.issues });
|
|
132
|
+
}
|
|
133
|
+
const ctx = {
|
|
134
|
+
widgets: spec.widgets,
|
|
135
|
+
dataSources,
|
|
136
|
+
registry,
|
|
137
|
+
interaction: resolvedInteraction,
|
|
138
|
+
};
|
|
139
|
+
return (_jsxs("div", { style: rootStyle, children: [spec.title ? _jsx("h2", { style: titleStyle, children: spec.title }) : null, renderLayoutNode(spec.layout, ctx, 'root')] }));
|
|
140
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
export type LayoutDirection = 'row' | 'column';
|
|
26
|
+
/** A recursive split node — maps onto the design-system's `SplitLayout` orientation. */
|
|
27
|
+
export type SplitLayoutNode = {
|
|
28
|
+
type: 'split';
|
|
29
|
+
direction: LayoutDirection;
|
|
30
|
+
/** Relative share of the parent split's main axis; children need not sum to 1. */
|
|
31
|
+
size?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Render this split as a DRAG-RESIZABLE panel group (design-system's
|
|
34
|
+
* `SplitLayout`, itself an Ark `Splitter`) instead of a plain flex box,
|
|
35
|
+
* with one panel per child and a resize handle between each pair. N-way by
|
|
36
|
+
* construction: three children give two handles.
|
|
37
|
+
*
|
|
38
|
+
* Opt-in rather than the default, because a `SplitLayout` needs hard panel
|
|
39
|
+
* sizes and therefore cannot flex-wrap: the non-resizable path keeps the
|
|
40
|
+
* responsive wrapping some dashboards rely on at narrow widths. A
|
|
41
|
+
* `direction: 'column'` split additionally needs {@link height}, since a
|
|
42
|
+
* vertical splitter has no content-driven size to divide.
|
|
43
|
+
*/
|
|
44
|
+
resizable?: boolean;
|
|
45
|
+
/** Required for a resizable `column` split: the CSS height to divide. */
|
|
46
|
+
height?: string;
|
|
47
|
+
children: LayoutNode[];
|
|
48
|
+
};
|
|
49
|
+
/** A leaf that resolves to one entry in the flat `widgets` registry. */
|
|
50
|
+
export type WidgetLayoutNode = {
|
|
51
|
+
type: 'widget';
|
|
52
|
+
/** Key into `DashboardSpec['widgets']`. */
|
|
53
|
+
ref: string;
|
|
54
|
+
/** Relative share of the parent split's main axis. */
|
|
55
|
+
size?: number;
|
|
56
|
+
};
|
|
57
|
+
export type LayoutNode = SplitLayoutNode | WidgetLayoutNode;
|
|
58
|
+
/**
|
|
59
|
+
* What data source feeds a widget, and how its fields map onto the shape the
|
|
60
|
+
* component needs — a typed channel-to-field mapping (Vega-Lite style)
|
|
61
|
+
* rather than an opaque query string. `source` ids are resolved through
|
|
62
|
+
* whatever {@link DashboardDataSources} table the host app supplies;
|
|
63
|
+
* `dashboard-kit` itself is agnostic to what backs a source (a mock array, a
|
|
64
|
+
* live tail, a replay cursor).
|
|
65
|
+
*/
|
|
66
|
+
export type DataBinding = {
|
|
67
|
+
/** Data-source id, e.g. `"stream:metrics.throughput"`. */
|
|
68
|
+
source: string;
|
|
69
|
+
/** Field name mapping: which field of a source record feeds which slot. */
|
|
70
|
+
fields: Record<string, string>;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Shared-selection keys a widget consumes (`reads`) or produces (`writes`).
|
|
74
|
+
* Symmetric by design — any widget may read or write any key, no
|
|
75
|
+
* master/detail hardcoding.
|
|
76
|
+
*/
|
|
77
|
+
export type WidgetInteraction = {
|
|
78
|
+
reads?: string[];
|
|
79
|
+
writes?: string[];
|
|
80
|
+
/**
|
|
81
|
+
* Per-field AGENT EXPOSURE: which of this widget's interaction keys an
|
|
82
|
+
* agent (a drive tool, a command bar) may write. **Default-deny**: a key a
|
|
83
|
+
* human can write through the UI is NOT agent-writable unless it is listed
|
|
84
|
+
* here.
|
|
85
|
+
*
|
|
86
|
+
* This is the manifest's half of the contract; a host app's agent-facing
|
|
87
|
+
* tools consult {@link collectAgentWritableKeys} and refuse anything
|
|
88
|
+
* outside it, so the agent surface is a declared, auditable property of
|
|
89
|
+
* the dashboard rather than "whatever setters happen to be in scope".
|
|
90
|
+
*/
|
|
91
|
+
agentWritable?: string[];
|
|
92
|
+
};
|
|
93
|
+
export type ThresholdSeverity = 'success' | 'warning' | 'critical';
|
|
94
|
+
export type ThresholdRule = {
|
|
95
|
+
op: 'gte' | 'lte' | 'gt' | 'lt' | 'eq';
|
|
96
|
+
value: number;
|
|
97
|
+
severity: ThresholdSeverity;
|
|
98
|
+
};
|
|
99
|
+
/** One declarative column for a table-rendering widget. */
|
|
100
|
+
export type WidgetTableColumn = {
|
|
101
|
+
accessorKey: string;
|
|
102
|
+
header: string;
|
|
103
|
+
/** How the registry's table adapter should render the cell. */
|
|
104
|
+
render?: 'text' | 'number' | 'currency' | 'percent' | 'badge' | 'sparkline';
|
|
105
|
+
};
|
|
106
|
+
export type WidgetNode = {
|
|
107
|
+
id: string;
|
|
108
|
+
/** Component-registry key, e.g. `"panel.stat"` or `"chart.line"`. */
|
|
109
|
+
component: string;
|
|
110
|
+
title?: string;
|
|
111
|
+
/**
|
|
112
|
+
* Component-specific configuration, passed through to the resolved
|
|
113
|
+
* component-registry entry. Kept to JSON-serializable shapes (no
|
|
114
|
+
* functions) so a manifest stays a genuine, inspectable data value —
|
|
115
|
+
* `columns` for table widgets is the one structured exception.
|
|
116
|
+
*/
|
|
117
|
+
props?: Record<string, unknown> & {
|
|
118
|
+
columns?: WidgetTableColumn[];
|
|
119
|
+
};
|
|
120
|
+
dataBinding?: DataBinding;
|
|
121
|
+
interaction?: WidgetInteraction;
|
|
122
|
+
thresholds?: ThresholdRule[];
|
|
123
|
+
};
|
|
124
|
+
export type DashboardSpec = {
|
|
125
|
+
version: 1;
|
|
126
|
+
title?: string;
|
|
127
|
+
layout: LayoutNode;
|
|
128
|
+
/** Flat registry of widget nodes, referenced by id from `layout`. */
|
|
129
|
+
widgets: Record<string, WidgetNode>;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Data-source table keyed by `DataBinding['source']` — every source resolves
|
|
133
|
+
* to an array of plain records; scalar widgets (e.g. a stat tile reading a
|
|
134
|
+
* gauge) read the last record. A host app owns populating this (from a mock
|
|
135
|
+
* fixture, a live subscription snapshot, a query result, ...); the engine
|
|
136
|
+
* only ever reads it.
|
|
137
|
+
*/
|
|
138
|
+
export type DashboardDataSources = Record<string, Record<string, unknown>[]>;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
import { z } from 'zod';
|
|
24
|
+
import type { DashboardSpec } from './schema.js';
|
|
25
|
+
export declare const dashboardSpecSchema: z.ZodObject<{
|
|
26
|
+
version: z.ZodLiteral<1>;
|
|
27
|
+
title: z.ZodOptional<z.ZodString>;
|
|
28
|
+
layout: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
|
|
29
|
+
widgets: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
30
|
+
id: z.ZodString;
|
|
31
|
+
component: z.ZodString;
|
|
32
|
+
title: z.ZodOptional<z.ZodString>;
|
|
33
|
+
props: z.ZodOptional<z.ZodObject<{
|
|
34
|
+
columns: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
35
|
+
accessorKey: z.ZodString;
|
|
36
|
+
header: z.ZodString;
|
|
37
|
+
render: z.ZodOptional<z.ZodEnum<{
|
|
38
|
+
badge: "badge";
|
|
39
|
+
currency: "currency";
|
|
40
|
+
number: "number";
|
|
41
|
+
percent: "percent";
|
|
42
|
+
sparkline: "sparkline";
|
|
43
|
+
text: "text";
|
|
44
|
+
}>>;
|
|
45
|
+
}, z.core.$strip>>>;
|
|
46
|
+
}, z.core.$loose>>;
|
|
47
|
+
dataBinding: z.ZodOptional<z.ZodObject<{
|
|
48
|
+
source: z.ZodString;
|
|
49
|
+
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
50
|
+
}, z.core.$strip>>;
|
|
51
|
+
interaction: z.ZodOptional<z.ZodObject<{
|
|
52
|
+
reads: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
53
|
+
writes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
54
|
+
agentWritable: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
55
|
+
}, z.core.$strip>>;
|
|
56
|
+
thresholds: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
57
|
+
op: z.ZodEnum<{
|
|
58
|
+
eq: "eq";
|
|
59
|
+
gt: "gt";
|
|
60
|
+
gte: "gte";
|
|
61
|
+
lt: "lt";
|
|
62
|
+
lte: "lte";
|
|
63
|
+
}>;
|
|
64
|
+
value: z.ZodNumber;
|
|
65
|
+
severity: z.ZodEnum<{
|
|
66
|
+
critical: "critical";
|
|
67
|
+
success: "success";
|
|
68
|
+
warning: "warning";
|
|
69
|
+
}>;
|
|
70
|
+
}, z.core.$strip>>>;
|
|
71
|
+
}, z.core.$strip>>;
|
|
72
|
+
}, z.core.$strip>;
|
|
73
|
+
export type ManifestIssue = {
|
|
74
|
+
/** Dotted path into the spec, e.g. `widgets.exposure.component`. */
|
|
75
|
+
path: string;
|
|
76
|
+
message: string;
|
|
77
|
+
};
|
|
78
|
+
export type ManifestValidation = {
|
|
79
|
+
ok: true;
|
|
80
|
+
spec: DashboardSpec;
|
|
81
|
+
issues: [];
|
|
82
|
+
} | {
|
|
83
|
+
ok: false;
|
|
84
|
+
issues: ManifestIssue[];
|
|
85
|
+
};
|
|
86
|
+
export type ValidateOptions = {
|
|
87
|
+
/**
|
|
88
|
+
* When supplied, every widget's `component` must be one of these keys.
|
|
89
|
+
* Pass `Object.keys(registry)` to catch a typo'd or hallucinated component
|
|
90
|
+
* name before it renders as an `UnknownWidget` placeholder.
|
|
91
|
+
*/
|
|
92
|
+
knownComponents?: Iterable<string>;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Validates a candidate manifest. Never throws — an agent-submitted patch is
|
|
96
|
+
* untrusted input, and the caller needs the reasons, not a stack trace.
|
|
97
|
+
*/
|
|
98
|
+
export declare function validateDashboardSpec(candidate: unknown, { knownComponents }?: ValidateOptions): ManifestValidation;
|
|
99
|
+
/**
|
|
100
|
+
* The set of interaction keys an agent is allowed to write, collected across
|
|
101
|
+
* every widget in the manifest. Default-deny: an empty set means an agent may
|
|
102
|
+
* read the dashboard but drive nothing.
|
|
103
|
+
*/
|
|
104
|
+
export declare function collectAgentWritableKeys(spec: DashboardSpec): Set<string>;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
import { z } from 'zod';
|
|
24
|
+
const layoutDirection = z.enum(['row', 'column']);
|
|
25
|
+
const widgetLayoutNode = z.object({
|
|
26
|
+
type: z.literal('widget'),
|
|
27
|
+
ref: z.string().min(1),
|
|
28
|
+
size: z.number().positive().optional(),
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* Recursive by `z.lazy` — a split's children are layout nodes, which may be
|
|
32
|
+
* splits.
|
|
33
|
+
*/
|
|
34
|
+
const layoutNode = z.lazy(() => z.union([widgetLayoutNode, splitLayoutNode]));
|
|
35
|
+
const splitLayoutNode = z.object({
|
|
36
|
+
type: z.literal('split'),
|
|
37
|
+
direction: layoutDirection,
|
|
38
|
+
size: z.number().positive().optional(),
|
|
39
|
+
resizable: z.boolean().optional(),
|
|
40
|
+
height: z.string().min(1).optional(),
|
|
41
|
+
children: z.array(layoutNode).min(1),
|
|
42
|
+
});
|
|
43
|
+
const dataBinding = z.object({
|
|
44
|
+
source: z.string().min(1),
|
|
45
|
+
fields: z.record(z.string(), z.string()),
|
|
46
|
+
});
|
|
47
|
+
const widgetInteraction = z.object({
|
|
48
|
+
reads: z.array(z.string().min(1)).optional(),
|
|
49
|
+
writes: z.array(z.string().min(1)).optional(),
|
|
50
|
+
agentWritable: z.array(z.string().min(1)).optional(),
|
|
51
|
+
});
|
|
52
|
+
const thresholdRule = z.object({
|
|
53
|
+
op: z.enum(['gte', 'lte', 'gt', 'lt', 'eq']),
|
|
54
|
+
value: z.number(),
|
|
55
|
+
severity: z.enum(['success', 'warning', 'critical']),
|
|
56
|
+
});
|
|
57
|
+
const widgetTableColumn = z.object({
|
|
58
|
+
accessorKey: z.string().min(1),
|
|
59
|
+
header: z.string(),
|
|
60
|
+
render: z
|
|
61
|
+
.enum(['text', 'number', 'currency', 'percent', 'badge', 'sparkline'])
|
|
62
|
+
.optional(),
|
|
63
|
+
});
|
|
64
|
+
const widgetNode = z.object({
|
|
65
|
+
id: z.string().min(1),
|
|
66
|
+
component: z.string().min(1),
|
|
67
|
+
title: z.string().optional(),
|
|
68
|
+
// `props` stays open (it's per-component config) EXCEPT `columns`, which is
|
|
69
|
+
// structured enough — and mistyped often enough — to be worth checking.
|
|
70
|
+
props: z
|
|
71
|
+
.looseObject({ columns: z.array(widgetTableColumn).optional() })
|
|
72
|
+
.optional(),
|
|
73
|
+
dataBinding: dataBinding.optional(),
|
|
74
|
+
interaction: widgetInteraction.optional(),
|
|
75
|
+
thresholds: z.array(thresholdRule).optional(),
|
|
76
|
+
});
|
|
77
|
+
export const dashboardSpecSchema = z.object({
|
|
78
|
+
version: z.literal(1),
|
|
79
|
+
title: z.string().optional(),
|
|
80
|
+
layout: layoutNode,
|
|
81
|
+
widgets: z.record(z.string(), widgetNode),
|
|
82
|
+
});
|
|
83
|
+
function walkLayout(node, path, visit) {
|
|
84
|
+
visit(node, path);
|
|
85
|
+
node.children?.forEach((child, index) => walkLayout(child, `${path}.children[${index}]`, visit));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Validates a candidate manifest. Never throws — an agent-submitted patch is
|
|
89
|
+
* untrusted input, and the caller needs the reasons, not a stack trace.
|
|
90
|
+
*/
|
|
91
|
+
export function validateDashboardSpec(candidate, { knownComponents } = {}) {
|
|
92
|
+
const parsed = dashboardSpecSchema.safeParse(candidate);
|
|
93
|
+
if (!parsed.success) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
issues: parsed.error.issues.map((issue) => ({
|
|
97
|
+
path: issue.path.join('.') || '(root)',
|
|
98
|
+
message: issue.message,
|
|
99
|
+
})),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const spec = parsed.data;
|
|
103
|
+
const issues = [];
|
|
104
|
+
const known = knownComponents ? new Set(knownComponents) : null;
|
|
105
|
+
// ── Referential pass ────────────────────────────────────────────────────
|
|
106
|
+
const referenced = new Set();
|
|
107
|
+
walkLayout(spec.layout, 'layout', (node, path) => {
|
|
108
|
+
if (node.type === 'widget') {
|
|
109
|
+
const ref = node.ref;
|
|
110
|
+
referenced.add(ref);
|
|
111
|
+
if (!(ref in spec.widgets)) {
|
|
112
|
+
issues.push({
|
|
113
|
+
path: `${path}.ref`,
|
|
114
|
+
message: `layout references unknown widget "${ref}" — add it to \`widgets\` or fix the ref`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (node.resizable && node.direction === 'column' && !node.height) {
|
|
120
|
+
issues.push({
|
|
121
|
+
path: `${path}.height`,
|
|
122
|
+
message: 'a resizable `column` split needs an explicit `height` — a vertical splitter has no content-driven size to divide',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
for (const [key, widget] of Object.entries(spec.widgets)) {
|
|
127
|
+
if (widget.id !== key) {
|
|
128
|
+
issues.push({
|
|
129
|
+
path: `widgets.${key}.id`,
|
|
130
|
+
message: `widget id "${widget.id}" does not match its registry key "${key}"`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (!referenced.has(key)) {
|
|
134
|
+
issues.push({
|
|
135
|
+
path: `widgets.${key}`,
|
|
136
|
+
message: `widget "${key}" is never placed by \`layout\` — it will not render`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (known && !known.has(widget.component)) {
|
|
140
|
+
issues.push({
|
|
141
|
+
path: `widgets.${key}.component`,
|
|
142
|
+
message: `unknown component "${widget.component}"`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// An agent-writable key that the widget cannot actually write is a policy
|
|
146
|
+
// that silently does nothing — worth catching at the gate. `writes` is the
|
|
147
|
+
// set of keys a widget produces; an `agentWritable` key must be one of them.
|
|
148
|
+
const writes = widget.interaction?.writes ?? [];
|
|
149
|
+
for (const exposed of widget.interaction?.agentWritable ?? []) {
|
|
150
|
+
if (!writes.includes(exposed)) {
|
|
151
|
+
issues.push({
|
|
152
|
+
path: `widgets.${key}.interaction.agentWritable`,
|
|
153
|
+
message: `"${exposed}" is marked agent-writable but is not in this widget's \`writes\``,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return issues.length > 0
|
|
159
|
+
? { ok: false, issues }
|
|
160
|
+
: { ok: true, spec, issues: [] };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The set of interaction keys an agent is allowed to write, collected across
|
|
164
|
+
* every widget in the manifest. Default-deny: an empty set means an agent may
|
|
165
|
+
* read the dashboard but drive nothing.
|
|
166
|
+
*/
|
|
167
|
+
export function collectAgentWritableKeys(spec) {
|
|
168
|
+
const keys = new Set();
|
|
169
|
+
for (const widget of Object.values(spec.widgets)) {
|
|
170
|
+
for (const key of widget.interaction?.agentWritable ?? [])
|
|
171
|
+
keys.add(key);
|
|
172
|
+
}
|
|
173
|
+
return keys;
|
|
174
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@r0hitsharma/dashboard-kit",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json",
|
|
22
|
+
"clean": "rm -rf dist",
|
|
23
|
+
"prepare": "npm run build",
|
|
24
|
+
"type:check": "tsc -p tsconfig.json --noEmit",
|
|
25
|
+
"lint": "oxlint -c oxlint.config.ts --max-warnings=0 src",
|
|
26
|
+
"lint:fix": "oxlint -c oxlint.config.ts --fix src",
|
|
27
|
+
"format": "oxfmt -c oxfmt.config.ts --write src",
|
|
28
|
+
"format:check": "oxfmt -c oxfmt.config.ts --check src",
|
|
29
|
+
"test": "vitest run"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@r0hitsharma/charting": "*",
|
|
33
|
+
"@r0hitsharma/design-system": "*",
|
|
34
|
+
"react": "^19.0.0",
|
|
35
|
+
"react-dom": "^19.0.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@tanstack/react-table": "9.2.4",
|
|
39
|
+
"zod": "4.5.4"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@r0hitsharma/charting": "*",
|
|
43
|
+
"@r0hitsharma/design-system": "*",
|
|
44
|
+
"@r0hitsharma/oxfmt-config": "*",
|
|
45
|
+
"@r0hitsharma/oxlint-config": "*",
|
|
46
|
+
"@r0hitsharma/tsconfig": "*",
|
|
47
|
+
"@testing-library/react": "^16.3.2",
|
|
48
|
+
"@types/react": "19.2.18",
|
|
49
|
+
"@types/react-dom": "19.2.7",
|
|
50
|
+
"jsdom": "^30.0.0",
|
|
51
|
+
"oxfmt": "0.67.0",
|
|
52
|
+
"oxlint": "1.82.0",
|
|
53
|
+
"react": "^19.0.0",
|
|
54
|
+
"react-dom": "^19.0.0",
|
|
55
|
+
"vitest": "^4.1.9"
|
|
56
|
+
},
|
|
57
|
+
"version": "0.12.0-rohit-fork-ci.1",
|
|
58
|
+
"repository": {
|
|
59
|
+
"url": "https://github.com/r0hitsharma/uikit"
|
|
60
|
+
}
|
|
61
|
+
}
|