diff --git a/packages/analytics/analytics-chart/package.json b/packages/analytics/analytics-chart/package.json index 4f94723e04..636dc0dd0a 100644 --- a/packages/analytics/analytics-chart/package.json +++ b/packages/analytics/analytics-chart/package.json @@ -86,6 +86,6 @@ }, "distSizeChecker": { "warningLimit": "1.35MB", - "errorLimit": "1.5MB" + "errorLimit": "1.6MB" } } diff --git a/packages/analytics/analytics-utilities/package.json b/packages/analytics/analytics-utilities/package.json index bd85d20da1..221007dd29 100644 --- a/packages/analytics/analytics-utilities/package.json +++ b/packages/analytics/analytics-utilities/package.json @@ -54,12 +54,13 @@ "extends": "../../../package.json" }, "distSizeChecker": { - "errorLimit": "800KB" + "errorLimit": "1100KB" }, "dependencies": { "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", - "lodash.clonedeep": "^4.5.0" + "lodash.clonedeep": "^4.5.0", + "zod": "^4.0.17" }, "devDependencies": { "@kong/design-tokens": "1.18.0", diff --git a/packages/analytics/analytics-utilities/src/dashboardSchemaZod.v2.spec.ts b/packages/analytics/analytics-utilities/src/dashboardSchemaZod.v2.spec.ts new file mode 100644 index 0000000000..746206215d --- /dev/null +++ b/packages/analytics/analytics-utilities/src/dashboardSchemaZod.v2.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { zDashboardConfig } from './dashboardSchemaZod.v2' +import type { DashboardConfig } from './dashboardSchemaZod.v2' + +describe('Dashboard schemas', () => { + it('successfully validates dashboard config schema', () => { + const definition: DashboardConfig = { + tiles: [ + { + type: 'chart', + definition: { + chart: { + type: 'horizontal_bar', + }, + query: { + datasource: 'basic', + }, + }, + layout: { + position: { + col: 1, + row: 1, + }, + size: { + cols: 1, + rows: 1, + }, + }, + }, + ], + } + const result = zDashboardConfig.safeParse(definition) + + expect(result.success).toBe(true) + }) + + it('dashboard validation fails for dashboard with invalid filter', () => { + const definition: DashboardConfig = { + tiles: [ + { + type: 'chart', + definition: { + chart: { + type: 'horizontal_bar', + }, + query: { + datasource: 'api_usage', + filters: [ + { + field: 'invalid_dimension', + operator: 'in', + value: ['value'], + }, + ], + }, + }, + layout: { + position: { + col: 1, + row: 1, + }, + size: { + cols: 1, + rows: 1, + }, + }, + }, + ], + } + const result = zDashboardConfig.safeParse(definition) + + expect(result.success).toBe(false) + expect(result.error?.issues).toHaveLength(1) + expect(result.error?.issues[0].code).toBe('invalid_value') + expect(result.error?.issues[0].message).toContain('Invalid option') + expect(result.error?.issues[0].path).toEqual(['tiles', 0, 'definition', 'query', 'filters', 0, 'field']) + }) +}) diff --git a/packages/analytics/analytics-utilities/src/dashboardSchemaZod.v2.ts b/packages/analytics/analytics-utilities/src/dashboardSchemaZod.v2.ts new file mode 100644 index 0000000000..b5d5915bbf --- /dev/null +++ b/packages/analytics/analytics-utilities/src/dashboardSchemaZod.v2.ts @@ -0,0 +1,233 @@ +import { z } from 'zod' +import { + aiExploreAggregations, + basicExploreAggregations, + exploreAggregations, + exploreFilterTypesV2, + filterableAiExploreDimensions, + filterableBasicExploreDimensions, + filterableExploreDimensions, + granularityValues, + queryableAiExploreDimensions, + queryableBasicExploreDimensions, + queryableExploreDimensions, + relativeTimeRangeValuesV4, + requestFilterTypeEmptyV2, +} from './types' + +export const dashboardTileTypes = [ + 'horizontal_bar', + 'vertical_bar', + 'gauge', + 'donut', + 'timeseries_line', + 'timeseries_bar', + 'golden_signals', + 'top_n', + 'slottable', + 'single_value', +] as const +export type DashboardTileType = typeof dashboardTileTypes[number] + +// Common chart props +const zSyntheticsDataKey = z.string() +const zChartTitle = z.string() +const zAllowCsvExport = z.boolean() + +// Can be either an array of strings or a { [key: string]: string } mapping +const zChartDatasetColors = z.union([z.array(z.string()), z.record(z.string(), z.string())]) + +// Chart schemas +export const zSlottableSchema = z.object({ + type: z.literal('slottable'), + id: z.string(), +}).strict() +export type SlottableOptions = z.infer + +export const zBarChartSchema = z.object({ + type: z.enum(['horizontal_bar', 'vertical_bar'] as const), + stacked: z.boolean().optional(), + chart_dataset_colors: zChartDatasetColors.optional(), + synthetics_data_key: zSyntheticsDataKey.optional(), + chart_title: zChartTitle.optional(), + allow_csv_export: zAllowCsvExport.optional(), +}).strict() +export type BarChartOptions = z.infer + +export const zTimeseriesChartSchema = z.object({ + type: z.enum(['timeseries_line', 'timeseries_bar'] as const), + stacked: z.boolean().optional(), + threshold: z.record(z.string(), z.number()).optional(), + chart_dataset_colors: zChartDatasetColors.optional(), + synthetics_data_key: zSyntheticsDataKey.optional(), + chart_title: zChartTitle.optional(), + allow_csv_export: zAllowCsvExport.optional(), +}).strict() +export type TimeseriesChartOptions = z.infer + +export const zGaugeChartSchema = z.object({ + type: z.literal('gauge'), + metric_display: z.enum(['hidden', 'single', 'full'] as const).optional(), + reverse_dataset: z.boolean().optional(), + numerator: z.number().optional(), + synthetics_data_key: zSyntheticsDataKey.optional(), + chart_title: zChartTitle.optional(), +}).strict() +export type GaugeChartOptions = z.infer + +export const zDonutChartSchema = z.object({ + type: z.literal('donut'), + synthetics_data_key: zSyntheticsDataKey.optional(), + chart_title: zChartTitle.optional(), +}).strict() +export type DonutChartOptions = z.infer + +export const zTopNTableSchema = z.object({ + type: z.literal('top_n'), + chart_title: zChartTitle.optional(), + synthetics_data_key: zSyntheticsDataKey.optional(), + description: z.string().optional(), + entity_link: z.string().optional(), +}).strict() +export type TopNTableOptions = z.infer + +export const zMetricCardSchema = z.object({ + type: z.literal('golden_signals'), + chart_title: zChartTitle.optional(), + long_card_titles: z.boolean().optional(), + description: z.string().optional(), + percentile_latency: z.boolean().optional(), +}).strict() +export type MetricCardOptions = z.infer + +export const zSingleValueSchema = z.object({ + type: z.literal('single_value'), + decimal_points: z.number().optional(), + chart_title: zChartTitle.optional(), +}).strict() +export type SingleValueOptions = z.infer + +const zChartOptions = z.discriminatedUnion('type', [ + zBarChartSchema, + zGaugeChartSchema, + zDonutChartSchema, + zTimeseriesChartSchema, + zMetricCardSchema, + zTopNTableSchema, + zSlottableSchema, + zSingleValueSchema, +]) + +export type ChartOptions = z.infer + +const zExploreV4RelativeTime = z.object({ + tz: z.string().optional().default('Etc/UTC'), + type: z.literal('relative'), + time_range: z.enum(relativeTimeRangeValuesV4).optional().default('1h'), +}).strict() + +const zExploreV4AbsoluteTime = z.object({ + tz: z.string().optional(), + type: z.literal('absolute'), + start: z.string(), + end: z.string(), +}).strict() + +const zTimeRange = z.discriminatedUnion('type', [zExploreV4RelativeTime, zExploreV4AbsoluteTime]).optional().default({ + tz: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + type: 'relative', + time_range: '1h', +}) + +// Query helpers +const zBaseQuery = z.object({ + granularity: z.enum(granularityValues).optional(), + time_range: zTimeRange.optional(), + limit: z.number().optional(), + meta: z.object().optional(), +}) + +// metrics(dimensions) helpers +const zMetrics = (aggs: readonly string[]) => z.array(z.enum(aggs as [string, ...string[]])).optional() +const zDimensions = (dims: readonly string[]) => z.array(z.enum(dims as [string, ...string[]])).max(2).optional() + +// Filters helper: supports "in" and "empty" operators for a given field enum or union +const zFilters = (filterableDimensions: T) => { + const zInFilter = z.object({ + field: z.enum(filterableDimensions), + operator: z.enum(exploreFilterTypesV2), + value: z.array(z.union([z.string(), z.number(), z.null()])), + }).strict() + + const zEmptyFilter = z.object({ + field: z.enum(filterableDimensions), + operator: z.enum(requestFilterTypeEmptyV2), + }).strict() + + return z.array(z.discriminatedUnion('operator', [zInFilter, zEmptyFilter])).optional() +} + +// Query schemas (per datasource) +export const zApiUsageQuery = zBaseQuery.extend({ + datasource: z.literal('api_usage'), + metrics: zMetrics(exploreAggregations).optional(), + dimensions: zDimensions(queryableExploreDimensions).optional(), + filters: zFilters(filterableExploreDimensions).optional(), +}).strict() +export const zBasicQuery = zBaseQuery.extend({ + datasource: z.literal('basic'), + metrics: zMetrics(basicExploreAggregations).optional(), + dimensions: zDimensions(queryableBasicExploreDimensions).optional(), + filters: zFilters(filterableBasicExploreDimensions).optional(), +}).strict() +export const zLlmUsageQuery = zBaseQuery.extend({ + datasource: z.literal('llm_usage'), + metrics: zMetrics(aiExploreAggregations).optional(), + dimensions: zDimensions(queryableAiExploreDimensions).optional(), + filters: zFilters(filterableAiExploreDimensions).optional(), +}).strict() + +export const zValidDashboardQuery = z.discriminatedUnion('datasource', [zApiUsageQuery, zBasicQuery, zLlmUsageQuery]) +export type ValidDashboardQuery = z.infer + +// Tile definition/layout/config schemas +export const zTileDefinition = z.object({ + query: zValidDashboardQuery, + chart: zChartOptions, +}).strict() +export type TileDefinition = z.infer + +export const zTileLayout = z.object({ + position: z.object({ + col: z.number(), + row: z.number(), + }).strict(), + size: z.object({ + cols: z.number(), + rows: z.number(), + fit_to_content: z.boolean().optional(), + }).strict(), +}).strict() +export type TileLayout = z.infer + +export const zTileConfig = z.object({ + type: z.enum(['chart']), + definition: zTileDefinition, + layout: zTileLayout, + id: z.string().optional(), +}).strict() +export type TileConfig = z.infer + +export const zDashboardConfig = z.object({ + tiles: z.array(zTileConfig), + tile_height: z.number().optional(), + preset_filters: zFilters([ + ...filterableExploreDimensions, + ...filterableBasicExploreDimensions, + ...filterableAiExploreDimensions, + ]), + template_id: z.string().nullable().optional(), +}).strict() +export type DashboardConfig = z.infer + +export const parseDashboardConfig = (input: unknown) => zDashboardConfig.parse(input) diff --git a/packages/analytics/analytics-utilities/src/index.ts b/packages/analytics/analytics-utilities/src/index.ts index 9823a254a3..6f4ac7600c 100644 --- a/packages/analytics/analytics-utilities/src/index.ts +++ b/packages/analytics/analytics-utilities/src/index.ts @@ -1,6 +1,6 @@ export * from './constants' export * as dashboardsSchemaV1 from './dashboardSchema' -export * from './dashboardSchema.v2' +export * from './dashboardSchemaZod.v2' export * from './types' export * from './filters' export * from './format' diff --git a/packages/analytics/dashboard-renderer/src/components/DashboardRenderer.spec.ts b/packages/analytics/dashboard-renderer/src/components/DashboardRenderer.spec.ts index 1730cc692a..1b293bc826 100644 --- a/packages/analytics/dashboard-renderer/src/components/DashboardRenderer.spec.ts +++ b/packages/analytics/dashboard-renderer/src/components/DashboardRenderer.spec.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from 'vitest' -import Ajv from 'ajv' -import { dashboardConfigSchema } from '@kong-ui-public/analytics-utilities' +import { zDashboardConfig } from '@kong-ui-public/analytics-utilities' -const ajv = new Ajv({ allowUnionTypes: true }) -const validate = ajv.compile(dashboardConfigSchema) +const validate = (data: unknown) => { + const result = zDashboardConfig.safeParse(data) + return result +} describe('Dashboard schemas', () => { it('successfully validates bar chart schemas', () => { @@ -32,8 +33,7 @@ describe('Dashboard schemas', () => { }, ], } - - expect(validate(definition)).toBe(true) + expect(validate(definition).success).toBe(true) }) it('successfully validates gauge chart schemas', () => { @@ -66,7 +66,7 @@ describe('Dashboard schemas', () => { ], } - expect(validate(definition)).toBe(true) + expect(validate(definition).success).toBe(true) }) it('rejects bad gauge chart schemas', () => { @@ -83,7 +83,7 @@ describe('Dashboard schemas', () => { ], } - expect(validate(definition1)).toBe(false) + expect(validate(definition1).success).toBe(false) const definition2: any = { tiles: [ @@ -97,9 +97,6 @@ describe('Dashboard schemas', () => { ], } - expect(validate(definition2)).toBe(false) - - // Note: Error messages aren't great right now because FromSchema doesn't understand - // the `discriminator` field, and AJV has limited support for it. + expect(validate(definition2).success).toBe(false) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b70c3571f..e2f37b82ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,9 @@ importers: lodash.clonedeep: specifier: ^4.5.0 version: 4.5.0 + zod: + specifier: ^4.0.17 + version: 4.0.17 devDependencies: '@kong/design-tokens': specifier: 1.18.0 @@ -9890,6 +9893,9 @@ packages: zenscroll@4.0.2: resolution: {integrity: sha512-jEA1znR7b4C/NnaycInCU6h/d15ZzCd1jmsruqOKnZP6WXQSMH3W2GL+OXbkruslU4h+Tzuos0HdswzRUk/Vgg==} + zod@4.0.17: + resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==} + snapshots: '@aashutoshrathi/word-wrap@1.2.6': {} @@ -19965,3 +19971,5 @@ snapshots: yocto-queue@1.2.1: {} zenscroll@4.0.2: {} + + zod@4.0.17: {}