Skip to content

Commit 8661c5f

Browse files
committed
feat: use zod for dashboard schema
BREAKING CHANGE: new dashboard schema using zod
1 parent 351aa04 commit 8661c5f

File tree

7 files changed

+333
-16
lines changed

7 files changed

+333
-16
lines changed

packages/analytics/analytics-chart/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,6 @@
8686
},
8787
"distSizeChecker": {
8888
"warningLimit": "1.35MB",
89-
"errorLimit": "1.5MB"
89+
"errorLimit": "1.6MB"
9090
}
9191
}

packages/analytics/analytics-utilities/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,13 @@
5454
"extends": "../../../package.json"
5555
},
5656
"distSizeChecker": {
57-
"errorLimit": "800KB"
57+
"errorLimit": "1100KB"
5858
},
5959
"dependencies": {
6060
"date-fns": "^4.1.0",
6161
"date-fns-tz": "^3.2.0",
62-
"lodash.clonedeep": "^4.5.0"
62+
"lodash.clonedeep": "^4.5.0",
63+
"zod": "^4.0.17"
6364
},
6465
"devDependencies": {
6566
"@kong/design-tokens": "1.18.0",
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { zDashboardConfig } from './dashboardSchemaZod.v2'
3+
import type { DashboardConfig } from './dashboardSchemaZod.v2'
4+
5+
describe('Dashboard schemas', () => {
6+
it('successfully validates dashboard config schema', () => {
7+
const definition: DashboardConfig = {
8+
tiles: [
9+
{
10+
type: 'chart',
11+
definition: {
12+
chart: {
13+
type: 'horizontal_bar',
14+
},
15+
query: {
16+
datasource: 'basic',
17+
},
18+
},
19+
layout: {
20+
position: {
21+
col: 1,
22+
row: 1,
23+
},
24+
size: {
25+
cols: 1,
26+
rows: 1,
27+
},
28+
},
29+
},
30+
],
31+
}
32+
const result = zDashboardConfig.safeParse(definition)
33+
34+
expect(result.success).toBe(true)
35+
})
36+
37+
it('dashboard validation fails for dashboard with invalid filter', () => {
38+
const definition: DashboardConfig = {
39+
tiles: [
40+
{
41+
type: 'chart',
42+
definition: {
43+
chart: {
44+
type: 'horizontal_bar',
45+
},
46+
query: {
47+
datasource: 'api_usage',
48+
filters: [
49+
{
50+
field: 'invalid_dimension',
51+
operator: 'in',
52+
value: ['value'],
53+
},
54+
],
55+
},
56+
},
57+
layout: {
58+
position: {
59+
col: 1,
60+
row: 1,
61+
},
62+
size: {
63+
cols: 1,
64+
rows: 1,
65+
},
66+
},
67+
},
68+
],
69+
}
70+
const result = zDashboardConfig.safeParse(definition)
71+
72+
expect(result.success).toBe(false)
73+
expect(result.error?.issues).toHaveLength(1)
74+
expect(result.error?.issues[0].code).toBe('invalid_value')
75+
expect(result.error?.issues[0].message).toContain('Invalid option')
76+
expect(result.error?.issues[0].path).toEqual(['tiles', 0, 'definition', 'query', 'filters', 0, 'field'])
77+
})
78+
})
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { z } from 'zod'
2+
import {
3+
aiExploreAggregations,
4+
basicExploreAggregations,
5+
exploreAggregations,
6+
exploreFilterTypesV2,
7+
filterableAiExploreDimensions,
8+
filterableBasicExploreDimensions,
9+
filterableExploreDimensions,
10+
granularityValues,
11+
queryableAiExploreDimensions,
12+
queryableBasicExploreDimensions,
13+
queryableExploreDimensions,
14+
relativeTimeRangeValuesV4,
15+
requestFilterTypeEmptyV2,
16+
} from './types'
17+
18+
export const dashboardTileTypes = [
19+
'horizontal_bar',
20+
'vertical_bar',
21+
'gauge',
22+
'donut',
23+
'timeseries_line',
24+
'timeseries_bar',
25+
'golden_signals',
26+
'top_n',
27+
'slottable',
28+
'single_value',
29+
] as const
30+
export type DashboardTileType = typeof dashboardTileTypes[number]
31+
32+
// Common chart props
33+
const zSyntheticsDataKey = z.string()
34+
const zChartTitle = z.string()
35+
const zAllowCsvExport = z.boolean()
36+
37+
// Can be either an array of strings or a { [key: string]: string } mapping
38+
const zChartDatasetColors = z.union([z.array(z.string()), z.record(z.string(), z.string())])
39+
40+
// Chart schemas
41+
export const zSlottableSchema = z.object({
42+
type: z.literal('slottable'),
43+
id: z.string(),
44+
}).strict()
45+
export type SlottableOptions = z.infer<typeof zSlottableSchema>
46+
47+
export const zBarChartSchema = z.object({
48+
type: z.enum(['horizontal_bar', 'vertical_bar'] as const),
49+
stacked: z.boolean().optional(),
50+
chart_dataset_colors: zChartDatasetColors.optional(),
51+
synthetics_data_key: zSyntheticsDataKey.optional(),
52+
chart_title: zChartTitle.optional(),
53+
allow_csv_export: zAllowCsvExport.optional(),
54+
}).strict()
55+
export type BarChartOptions = z.infer<typeof zBarChartSchema>
56+
57+
export const zTimeseriesChartSchema = z.object({
58+
type: z.enum(['timeseries_line', 'timeseries_bar'] as const),
59+
stacked: z.boolean().optional(),
60+
threshold: z.record(z.string(), z.number()).optional(),
61+
chart_dataset_colors: zChartDatasetColors.optional(),
62+
synthetics_data_key: zSyntheticsDataKey.optional(),
63+
chart_title: zChartTitle.optional(),
64+
allow_csv_export: zAllowCsvExport.optional(),
65+
}).strict()
66+
export type TimeseriesChartOptions = z.infer<typeof zTimeseriesChartSchema>
67+
68+
export const zGaugeChartSchema = z.object({
69+
type: z.literal('gauge'),
70+
metric_display: z.enum(['hidden', 'single', 'full'] as const).optional(),
71+
reverse_dataset: z.boolean().optional(),
72+
numerator: z.number().optional(),
73+
synthetics_data_key: zSyntheticsDataKey.optional(),
74+
chart_title: zChartTitle.optional(),
75+
}).strict()
76+
export type GaugeChartOptions = z.infer<typeof zGaugeChartSchema>
77+
78+
export const zDonutChartSchema = z.object({
79+
type: z.literal('donut'),
80+
synthetics_data_key: zSyntheticsDataKey.optional(),
81+
chart_title: zChartTitle.optional(),
82+
}).strict()
83+
export type DonutChartOptions = z.infer<typeof zDonutChartSchema>
84+
85+
export const zTopNTableSchema = z.object({
86+
type: z.literal('top_n'),
87+
chart_title: zChartTitle.optional(),
88+
synthetics_data_key: zSyntheticsDataKey.optional(),
89+
description: z.string().optional(),
90+
entity_link: z.string().optional(),
91+
}).strict()
92+
export type TopNTableOptions = z.infer<typeof zTopNTableSchema>
93+
94+
export const zMetricCardSchema = z.object({
95+
type: z.literal('golden_signals'),
96+
chart_title: zChartTitle.optional(),
97+
long_card_titles: z.boolean().optional(),
98+
description: z.string().optional(),
99+
percentile_latency: z.boolean().optional(),
100+
}).strict()
101+
export type MetricCardOptions = z.infer<typeof zMetricCardSchema>
102+
103+
export const zSingleValueSchema = z.object({
104+
type: z.literal('single_value'),
105+
decimal_points: z.number().optional(),
106+
chart_title: zChartTitle.optional(),
107+
}).strict()
108+
export type SingleValueOptions = z.infer<typeof zSingleValueSchema>
109+
110+
const zChartOptions = z.discriminatedUnion('type', [
111+
zBarChartSchema,
112+
zGaugeChartSchema,
113+
zDonutChartSchema,
114+
zTimeseriesChartSchema,
115+
zMetricCardSchema,
116+
zTopNTableSchema,
117+
zSlottableSchema,
118+
zSingleValueSchema,
119+
])
120+
121+
export type ChartOptions = z.infer<typeof zChartOptions>
122+
123+
const zExploreV4RelativeTime = z.object({
124+
tz: z.string().optional().default('Etc/UTC'),
125+
type: z.literal('relative'),
126+
time_range: z.enum(relativeTimeRangeValuesV4).optional().default('1h'),
127+
}).strict()
128+
129+
const zExploreV4AbsoluteTime = z.object({
130+
tz: z.string().optional(),
131+
type: z.literal('absolute'),
132+
start: z.string(),
133+
end: z.string(),
134+
}).strict()
135+
136+
const zTimeRange = z.discriminatedUnion('type', [zExploreV4RelativeTime, zExploreV4AbsoluteTime]).optional().default({
137+
tz: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
138+
type: 'relative',
139+
time_range: '1h',
140+
})
141+
142+
// Query helpers
143+
const zBaseQuery = z.object({
144+
granularity: z.enum(granularityValues).optional(),
145+
time_range: zTimeRange.optional(),
146+
limit: z.number().optional(),
147+
meta: z.object().optional(),
148+
})
149+
150+
// metrics(dimensions) helpers
151+
const zMetrics = (aggs: readonly string[]) => z.array(z.enum(aggs as [string, ...string[]])).optional()
152+
const zDimensions = (dims: readonly string[]) => z.array(z.enum(dims as [string, ...string[]])).max(2).optional()
153+
154+
// Filters helper: supports "in" and "empty" operators for a given field enum or union
155+
const zFilters = <T extends readonly string[]>(filterableDimensions: T) => {
156+
const zInFilter = z.object({
157+
field: z.enum(filterableDimensions),
158+
operator: z.enum(exploreFilterTypesV2),
159+
value: z.array(z.union([z.string(), z.number(), z.null()])),
160+
}).strict()
161+
162+
const zEmptyFilter = z.object({
163+
field: z.enum(filterableDimensions),
164+
operator: z.enum(requestFilterTypeEmptyV2),
165+
}).strict()
166+
167+
return z.array(z.discriminatedUnion('operator', [zInFilter, zEmptyFilter])).optional()
168+
}
169+
170+
// Query schemas (per datasource)
171+
export const zApiUsageQuery = zBaseQuery.extend({
172+
datasource: z.literal('api_usage'),
173+
metrics: zMetrics(exploreAggregations).optional(),
174+
dimensions: zDimensions(queryableExploreDimensions).optional(),
175+
filters: zFilters(filterableExploreDimensions).optional(),
176+
}).strict()
177+
export const zBasicQuery = zBaseQuery.extend({
178+
datasource: z.literal('basic'),
179+
metrics: zMetrics(basicExploreAggregations).optional(),
180+
dimensions: zDimensions(queryableBasicExploreDimensions).optional(),
181+
filters: zFilters(filterableBasicExploreDimensions).optional(),
182+
}).strict()
183+
export const zLlmUsageQuery = zBaseQuery.extend({
184+
datasource: z.literal('llm_usage'),
185+
metrics: zMetrics(aiExploreAggregations).optional(),
186+
dimensions: zDimensions(queryableAiExploreDimensions).optional(),
187+
filters: zFilters(filterableAiExploreDimensions).optional(),
188+
}).strict()
189+
190+
export const zValidDashboardQuery = z.discriminatedUnion('datasource', [zApiUsageQuery, zBasicQuery, zLlmUsageQuery])
191+
export type ValidDashboardQuery = z.infer<typeof zValidDashboardQuery>
192+
193+
// Tile definition/layout/config schemas
194+
export const zTileDefinition = z.object({
195+
query: zValidDashboardQuery,
196+
chart: zChartOptions,
197+
}).strict()
198+
export type TileDefinition = z.infer<typeof zTileDefinition>
199+
200+
export const zTileLayout = z.object({
201+
position: z.object({
202+
col: z.number(),
203+
row: z.number(),
204+
}).strict(),
205+
size: z.object({
206+
cols: z.number(),
207+
rows: z.number(),
208+
fit_to_content: z.boolean().optional(),
209+
}).strict(),
210+
}).strict()
211+
export type TileLayout = z.infer<typeof zTileLayout>
212+
213+
export const zTileConfig = z.object({
214+
type: z.enum(['chart']),
215+
definition: zTileDefinition,
216+
layout: zTileLayout,
217+
id: z.string().optional(),
218+
}).strict()
219+
export type TileConfig = z.infer<typeof zTileConfig>
220+
221+
export const zDashboardConfig = z.object({
222+
tiles: z.array(zTileConfig),
223+
tile_height: z.number().optional(),
224+
preset_filters: zFilters([
225+
...filterableExploreDimensions,
226+
...filterableBasicExploreDimensions,
227+
...filterableAiExploreDimensions,
228+
]),
229+
template_id: z.string().nullable().optional(),
230+
}).strict()
231+
export type DashboardConfig = z.infer<typeof zDashboardConfig>
232+
233+
export const parseDashboardConfig = (input: unknown) => zDashboardConfig.parse(input)

packages/analytics/analytics-utilities/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export * from './constants'
22
export * as dashboardsSchemaV1 from './dashboardSchema'
3-
export * from './dashboardSchema.v2'
3+
export * from './dashboardSchemaZod.v2'
44
export * from './types'
55
export * from './filters'
66
export * from './format'

packages/analytics/dashboard-renderer/src/components/DashboardRenderer.spec.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { describe, it, expect } from 'vitest'
2-
import Ajv from 'ajv'
3-
import { dashboardConfigSchema } from '@kong-ui-public/analytics-utilities'
2+
import { zDashboardConfig } from '@kong-ui-public/analytics-utilities'
43

5-
const ajv = new Ajv({ allowUnionTypes: true })
6-
const validate = ajv.compile(dashboardConfigSchema)
4+
const validate = (data: unknown) => {
5+
const result = zDashboardConfig.safeParse(data)
6+
return result
7+
}
78

89
describe('Dashboard schemas', () => {
910
it('successfully validates bar chart schemas', () => {
@@ -32,8 +33,7 @@ describe('Dashboard schemas', () => {
3233
},
3334
],
3435
}
35-
36-
expect(validate(definition)).toBe(true)
36+
expect(validate(definition).success).toBe(true)
3737
})
3838

3939
it('successfully validates gauge chart schemas', () => {
@@ -66,7 +66,7 @@ describe('Dashboard schemas', () => {
6666
],
6767
}
6868

69-
expect(validate(definition)).toBe(true)
69+
expect(validate(definition).success).toBe(true)
7070
})
7171

7272
it('rejects bad gauge chart schemas', () => {
@@ -83,7 +83,7 @@ describe('Dashboard schemas', () => {
8383
],
8484
}
8585

86-
expect(validate(definition1)).toBe(false)
86+
expect(validate(definition1).success).toBe(false)
8787

8888
const definition2: any = {
8989
tiles: [
@@ -97,9 +97,6 @@ describe('Dashboard schemas', () => {
9797
],
9898
}
9999

100-
expect(validate(definition2)).toBe(false)
101-
102-
// Note: Error messages aren't great right now because FromSchema doesn't understand
103-
// the `discriminator` field, and AJV has limited support for it.
100+
expect(validate(definition2).success).toBe(false)
104101
})
105102
})

0 commit comments

Comments
 (0)