diff --git a/packages/analytics/analytics-config-store/src/stores/datasource-config.ts b/packages/analytics/analytics-config-store/src/stores/datasource-config.ts index e84471c535..5d82d1bece 100644 --- a/packages/analytics/analytics-config-store/src/stores/datasource-config.ts +++ b/packages/analytics/analytics-config-store/src/stores/datasource-config.ts @@ -1,7 +1,8 @@ import { defineStore } from 'pinia' import { computed, inject, ref, watch } from 'vue' -import { INJECT_QUERY_PROVIDER } from '@kong-ui-public/analytics-utilities' -import type { AnalyticsBridge, DatasourceConfig, Field } from '@kong-ui-public/analytics-utilities' +import type { AllFilters, AnalyticsBridge, DatasourceConfig, Field } from '@kong-ui-public/analytics-utilities' + +const INJECT_QUERY_PROVIDER = 'analytics-query-provider' export type MappedDatasourceConfig = DatasourceConfig & { fieldsMap: Record @@ -76,12 +77,89 @@ export const useDatasourceConfigStore = defineStore('datasource-config', () => { } }) + const isFilterValidForDatasource = computed(() => { + return ({ + datasource, + filter, + }: { + datasource: string + filter: AllFilters + }): boolean => { + // If datasource config is not ready yet assume filter is valid. + // Once config is loaded, it will be re-evaluated. + if (loading.value) { + return true + } + const datasourceConfigEntry = datasourceConfigMap.value[datasource] + + // If we don't find a datasource assume the filter is valid. + // We may be dealing with a goap datasource that we don't have config for. + if (!datasourceConfigEntry) { + return true + } + + const field = datasourceConfigEntry.fieldsMap[filter.field] + + if (!field?.filter) { + return false + } + + return field.filter.operators.flatMap(operator => operator.ops).includes(filter.operator) + } + }) + + const stripUnknownFilters = computed(() => { + return ({ + datasource, + filters, + metrics = undefined, + }: { + datasource: string + filters: AllFilters[] + metrics?: readonly string[] + }): AllFilters[] => { + const filtered = filters.filter(filter => isFilterValidForDatasource.value({ datasource, filter })) + + if (!metrics?.length) { + return filtered + } + + // If metrics are provided, further filter the dimensions based on which dimensions are supported by the metric + const datasourceConfigEntry = datasourceConfigMap.value[datasource] + if (!datasourceConfigEntry) { + return filtered + } + + const metricFields = metrics + .map(metric => datasourceConfigEntry.fieldsMap[metric]) + .filter((field) => field !== undefined) + + const fieldsWithSupportedDimensions = metricFields.filter((field) => { + return field.supportedDimensions !== undefined + }) + + const supportedDimensionSets: Array> = fieldsWithSupportedDimensions.map(field => new Set(field.supportedDimensions)) + + if (!supportedDimensionSets.length) { + return filtered + } + + const supportedDimensions = supportedDimensionSets.reduce((intersection, dimensions) => { + return dimensions.intersection(intersection) + }) + + return filtered.filter(filter => supportedDimensions.has(filter.field)) + } + }) + return { datasourceConfig, datasourceConfigError, datasourceConfigMap, getFieldDataSources, + isFilterValidForDatasource, loading, isReady, + stripUnknownFilters, } }) diff --git a/packages/analytics/analytics-config-store/src/stores/tests/datasource-config.spec.ts b/packages/analytics/analytics-config-store/src/stores/tests/datasource-config.spec.ts index e3692184f4..77861f32c7 100644 --- a/packages/analytics/analytics-config-store/src/stores/tests/datasource-config.spec.ts +++ b/packages/analytics/analytics-config-store/src/stores/tests/datasource-config.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { App } from 'vue' import { nextTick } from 'vue' -import type { DatasourceConfig } from '@kong-ui-public/analytics-utilities' +import type { AllFilters, DatasourceConfig } from '@kong-ui-public/analytics-utilities' import { useDatasourceConfigStore } from '../datasource-config' import { setupPiniaTestStore } from './setupPiniaTestStore' @@ -25,6 +25,39 @@ describe('useDatasourceConfigStore', () => { operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], }, }, + { + name: 'gateway_service', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'route', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'consumer', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, { name: 'request_count', showInUI: true, @@ -32,7 +65,40 @@ describe('useDatasourceConfigStore', () => { group: false, metricGroup: 'count', }, + { + name: 'request_size', + showInUI: true, + aggregation: true, + group: false, + metricGroup: 'sum', + supportedDimensions: ['status_code', 'gateway_service'], + }, + { + name: 'error_rate', + showInUI: true, + aggregation: true, + group: false, + metricGroup: 'rate', + supportedDimensions: ['status_code', 'route'], + }, + { + name: 'consumer_count', + showInUI: true, + aggregation: true, + group: false, + metricGroup: 'count', + supportedDimensions: ['consumer'], + }, + { + name: 'no_dimensions_metric', + showInUI: true, + aggregation: true, + group: false, + metricGroup: 'count', + supportedDimensions: [], + }, ], + timeRangeOptions: [], }, { name: 'requests', @@ -62,6 +128,7 @@ describe('useDatasourceConfigStore', () => { }, }, ], + timeRangeOptions: [], }, ] @@ -89,6 +156,12 @@ describe('useDatasourceConfigStore', () => { return useDatasourceConfigStore() } + const makeFilter = (field: string): AllFilters => ({ + field, + operator: 'in', + value: ['value'], + } as AllFilters) + it('loads datasource config and builds a fields map', async () => { const store = useStore() @@ -154,4 +227,208 @@ describe('useDatasourceConfigStore', () => { expect(store.loading).toBe(false) expect(store.datasourceConfig).toEqual([]) }) + + describe('isFilterValidForDatasource', () => { + it('returns true for a valid filter', async () => { + const store = useStore() + await store.isReady() + + const filter = { + field: 'status_code', + operator: 'in', + value: ['200'], + } as AllFilters + + expect(store.isFilterValidForDatasource({ datasource: 'api_usage', filter })).toBe(true) + }) + + it('returns false when the field is not in the datasource', async () => { + const store = useStore() + await store.isReady() + + const filter = { + field: 'workspace', + operator: 'in', + value: ['route-id'], + } as AllFilters + + expect(store.isFilterValidForDatasource({ datasource: 'api_usage', filter })).toBe(false) + }) + + it('returns false when the field has no filter config', async () => { + const store = useStore() + await store.isReady() + + const filter = { + field: 'request_count', + operator: 'in', + value: ['200'], + } as AllFilters + + expect(store.isFilterValidForDatasource({ datasource: 'api_usage', filter })).toBe(false) + }) + + it('returns false when the operator is not supported', async () => { + const store = useStore() + await store.isReady() + + const filter = { + field: 'status_code', + operator: '=', + value: ['200'], + } as AllFilters + + expect(store.isFilterValidForDatasource({ datasource: 'api_usage', filter })).toBe(false) + }) + + it('returns true for unknown datasources', async () => { + const store = useStore() + await store.isReady() + + const filter = { + field: 'unknown_field', + operator: 'in', + value: ['value'], + } as AllFilters + + expect(store.isFilterValidForDatasource({ datasource: 'goap_test', filter })).toBe(true) + }) + }) + + describe('stripUnknownFilters', () => { + it('removes invalid filters and keeps valid ones', async () => { + const store = useStore() + await store.isReady() + + const validFilter = { + field: 'status_code', + operator: 'in', + value: ['200'], + } as AllFilters + + const invalidFilter = { + field: 'request_count', + operator: 'in', + value: ['200'], + } as AllFilters + + const result = store.stripUnknownFilters({ + datasource: 'api_usage', + filters: [invalidFilter, validFilter], + }) + + expect(result).toEqual([validFilter]) + }) + + it('keeps all filters for unknown datasources', async () => { + const store = useStore() + await store.isReady() + + const filters = [ + { + field: 'status_code', + operator: 'in', + value: ['200'], + }, + { + field: 'unknown_field', + operator: 'in', + value: ['value'], + }, + ] as AllFilters[] + + expect(store.stripUnknownFilters({ datasource: 'goap_test', filters })).toEqual(filters) + }) + + it('strips unsupported filters for a single metric with supported dimensions', async () => { + const store = useStore() + await store.isReady() + + const filters = [makeFilter('status_code'), makeFilter('gateway_service'), makeFilter('consumer')] + + expect(store.stripUnknownFilters({ + datasource: 'api_usage', + filters, + metrics: ['request_size'], + })).toEqual([makeFilter('status_code'), makeFilter('gateway_service')]) + }) + + it('keeps datasource-valid filters when a single metric has supportedDimensions unset', async () => { + const store = useStore() + await store.isReady() + + const filters = [makeFilter('status_code'), makeFilter('gateway_service'), makeFilter('consumer')] + + expect(store.stripUnknownFilters({ + datasource: 'api_usage', + filters, + metrics: ['request_count'], + })).toEqual(filters) + }) + + it('strips all filters when a single metric has an empty supportedDimensions array', async () => { + const store = useStore() + await store.isReady() + + const filters = [makeFilter('status_code'), makeFilter('gateway_service')] + + expect(store.stripUnknownFilters({ + datasource: 'api_usage', + filters, + metrics: ['no_dimensions_metric'], + })).toEqual([]) + }) + + it('keeps only the intersection for multiple metrics with supported dimensions', async () => { + const store = useStore() + await store.isReady() + + const filters = [makeFilter('status_code'), makeFilter('gateway_service'), makeFilter('route')] + + expect(store.stripUnknownFilters({ + datasource: 'api_usage', + filters, + metrics: ['request_size', 'error_rate'], + })).toEqual([makeFilter('status_code')]) + }) + + it('strips all filters when one of multiple metrics supports no dimensions', async () => { + const store = useStore() + await store.isReady() + + const filters = [makeFilter('status_code'), makeFilter('gateway_service')] + + expect(store.stripUnknownFilters({ + datasource: 'api_usage', + filters, + metrics: ['request_size', 'no_dimensions_metric'], + })).toEqual([]) + }) + + it('ignores metrics with supportedDimensions unset when intersecting multiple metrics', async () => { + const store = useStore() + await store.isReady() + + const filters = [makeFilter('status_code'), makeFilter('gateway_service'), makeFilter('consumer')] + + expect(store.stripUnknownFilters({ + datasource: 'api_usage', + filters, + metrics: ['request_size', 'request_count'], + })).toEqual([makeFilter('status_code'), makeFilter('gateway_service')]) + }) + + it('strips all filters when multiple metrics have no supported dimension intersection', async () => { + const store = useStore() + await store.isReady() + + const filters = [makeFilter('status_code'), makeFilter('gateway_service'), makeFilter('consumer')] + + expect(store.stripUnknownFilters({ + datasource: 'api_usage', + filters, + metrics: ['request_size', 'consumer_count'], + })).toEqual([]) + }) + }) }) diff --git a/packages/analytics/analytics-utilities/package.json b/packages/analytics/analytics-utilities/package.json index df1fbdb2ea..fc5b25cc16 100644 --- a/packages/analytics/analytics-utilities/package.json +++ b/packages/analytics/analytics-utilities/package.json @@ -68,6 +68,7 @@ "devDependencies": { "@kong-ui-public/i18n": "workspace:^", "@kong/design-tokens": "1.20.0", + "ajv": "^8.17.1", "json-schema-to-ts": "^3.1.1", "vue": "^3.5.27" } diff --git a/packages/analytics/analytics-utilities/src/dashboardSchema.v2.spec.ts b/packages/analytics/analytics-utilities/src/dashboardSchema.v2.spec.ts new file mode 100644 index 0000000000..ad548cf477 --- /dev/null +++ b/packages/analytics/analytics-utilities/src/dashboardSchema.v2.spec.ts @@ -0,0 +1,211 @@ +import Ajv from 'ajv' +import { describe, expect, it } from 'vitest' +import { + apiUsageQuerySchema, + barChartSchema, + basicQuerySchema, + dashboardConfigSchema, + llmUsageSchema, + mcpUsageSchema, + validDashboardQuery, + platformQuerySchema, +} from './dashboardSchema.v2' +import { + aiExploreAggregations, + basicExploreAggregations, + exploreAggregations, + exploreFilterTypesV2, + filterableAiExploreDimensions, + filterableBasicExploreDimensions, + filterableExploreDimensions, + filterableMcpExploreDimensions, + mcpExploreAggregations, + queryableAiExploreDimensions, + queryableBasicExploreDimensions, + queryableExploreDimensions, + queryableMcpExploreDimensions, + requestFilterTypeEmptyV2, +} from './types' + +const ajv = new Ajv({ allowUnionTypes: true }) +const validateValidDashboardQuery = ajv.compile(validDashboardQuery) +const validatePlatformQuerySchema = ajv.compile(platformQuerySchema) +const validateDashboardConfigSchema = ajv.compile(dashboardConfigSchema) +const validateApiUsageQuerySchema = ajv.compile(apiUsageQuerySchema) +const validateBasicQuerySchema = ajv.compile(basicQuerySchema) +const validateLlmUsageQuerySchema = ajv.compile(llmUsageSchema) +const validateMcpUsageQuerySchema = ajv.compile(mcpUsageSchema) + +describe('dashboardSchema.v2', () => { + const sharedPresetFilterableDimensions = [ + ...new Set([ + ...filterableExploreDimensions, + ...filterableBasicExploreDimensions, + ...filterableAiExploreDimensions, + ...filterableMcpExploreDimensions, + ]), + ] + + const platformQuery = { + datasource: 'platform', + metrics: ['custom_metric_name'], + dimensions: ['custom_dimension_name'], + filters: [ + { + operator: 'custom_platform_operator', + field: 'custom_filter_field', + value: ['custom_value'], + }, + ], + } + + const dashboardConfig = { + tiles: [ + { + type: 'chart', + definition: { + query: { + ...platformQuery, + }, + chart: { + type: 'horizontal_bar', + }, + }, + layout: { + position: { + col: 1, + row: 1, + }, + size: { + cols: 1, + rows: 1, + }, + }, + }, + ], + preset_filters: [ + { + operator: 'in', + field: 'gateway_service', + value: ['example-service'], + }, + ], + } + + const mixedDashboardConfig = { + ...dashboardConfig, + tiles: [ + dashboardConfig.tiles[0], + { + type: 'chart', + definition: { + query: { + datasource: 'api_usage', + metrics: ['request_count'], + dimensions: ['gateway_service'], + filters: [ + { + operator: 'in', + field: 'gateway_service', + value: ['example-service'], + }, + ], + }, + chart: { + type: 'horizontal_bar', + }, + }, + layout: { + position: { + col: 2, + row: 1, + }, + size: { + cols: 1, + rows: 1, + }, + }, + }, + ], + } + + const strictQuery = { + datasource: 'api_usage', + metrics: ['request_count'], + dimensions: ['gateway_service'], + filters: [ + { + operator: 'in', + field: 'gateway_service', + value: ['example-service'], + }, + ], + } + + it('accepts platform queries with arbitrary strings at runtime', () => { + expect(validatePlatformQuerySchema(platformQuery)).toBe(true) + expect(validateValidDashboardQuery(platformQuery)).toBe(true) + expect(validateDashboardConfigSchema(dashboardConfig)).toBe(true) + expect(validateDashboardConfigSchema(mixedDashboardConfig)).toBe(true) + }) + + it.each([ + [apiUsageQuerySchema, exploreAggregations, queryableExploreDimensions, filterableExploreDimensions], + [basicQuerySchema, basicExploreAggregations, queryableBasicExploreDimensions, filterableBasicExploreDimensions], + [llmUsageSchema, aiExploreAggregations, queryableAiExploreDimensions, filterableAiExploreDimensions], + [mcpUsageSchema, mcpExploreAggregations, queryableMcpExploreDimensions, filterableMcpExploreDimensions], + ])('keeps strict enums for existing datasource schemas', (schema, expectedMetrics, expectedDimensions, expectedFilterableDimensions) => { + expect(schema.properties.datasource.enum).toHaveLength(1) + expect(schema.properties.metrics.items.enum).toEqual(expectedMetrics) + expect(schema.properties.dimensions.items.enum).toEqual(expectedDimensions) + expect(schema.properties.filters.items.oneOf[0].properties.field.enum).toEqual(expectedFilterableDimensions) + expect(schema.properties.filters.items.oneOf[1].properties.field.enum).toEqual(expectedFilterableDimensions) + }) + + it('loosens only the platform branch', () => { + expect(platformQuerySchema.properties.datasource.enum).toEqual(['platform']) + expect(platformQuerySchema.properties.metrics.items.enum).toBeUndefined() + expect(platformQuerySchema.properties.dimensions.items.enum).toBeUndefined() + expect(platformQuerySchema.properties.filters.items.oneOf[0].properties.field.enum).toBeUndefined() + expect(platformQuerySchema.properties.filters.items.oneOf[1].properties.field.enum).toBeUndefined() + expect(platformQuerySchema.properties.filters.items.oneOf[0].properties.operator.enum).toBeUndefined() + expect(platformQuerySchema.properties.filters.items.oneOf[1].properties.operator.enum).toBeUndefined() + }) + + it('keeps shared preset filters strict and operator enums intact', () => { + expect(dashboardConfigSchema.properties.preset_filters.items.oneOf[0].properties.field.enum).toEqual(sharedPresetFilterableDimensions) + expect(dashboardConfigSchema.properties.preset_filters.items.oneOf[1].properties.field.enum).toEqual(sharedPresetFilterableDimensions) + expect(dashboardConfigSchema.properties.preset_filters.items.oneOf[0].properties.operator.enum).toEqual(exploreFilterTypesV2) + expect(dashboardConfigSchema.properties.preset_filters.items.oneOf[1].properties.operator.enum).toEqual(requestFilterTypeEmptyV2) + expect(barChartSchema.properties.type.enum).toEqual(['horizontal_bar', 'vertical_bar']) + }) + + it('rejects arbitrary strings for strict schemas', () => { + const invalidStrictQuery = { + ...strictQuery, + metrics: ['custom_metric_name'], + dimensions: ['custom_dimension_name'], + filters: [ + { + operator: 'in', + field: 'custom_filter_field', + value: ['custom_value'], + }, + ], + } + + expect(validateApiUsageQuerySchema(invalidStrictQuery)).toBe(false) + expect(validateBasicQuerySchema({ + ...invalidStrictQuery, + datasource: 'basic', + })).toBe(false) + expect(validateLlmUsageQuerySchema({ + ...invalidStrictQuery, + datasource: 'llm_usage', + })).toBe(false) + expect(validateMcpUsageQuerySchema({ + ...invalidStrictQuery, + datasource: 'agentic_usage', + })).toBe(false) + }) +}) diff --git a/packages/analytics/analytics-utilities/src/dashboardSchema.v2.ts b/packages/analytics/analytics-utilities/src/dashboardSchema.v2.ts index 27f4823abb..ce9bfa70b5 100644 --- a/packages/analytics/analytics-utilities/src/dashboardSchema.v2.ts +++ b/packages/analytics/analytics-utilities/src/dashboardSchema.v2.ts @@ -369,27 +369,27 @@ const baseQueryProperties = { }, } as const -const metricsFn = (aggregations: T) => ({ +const metricsFn = (aggregations?: T) => ({ type: 'array', description: 'List of aggregated metrics to collect across the requested time span.', items: { type: 'string', - enum: aggregations, + ...(aggregations ? { enum: aggregations } : {}), }, } as const satisfies JSONSchema) -const dimensionsFn = (dimensions: T) => ({ +const dimensionsFn = (dimensions?: T) => ({ type: 'array', description: 'List of attributes or entity types to group by.', minItems: 0, maxItems: 2, items: { type: 'string', - enum: dimensions, + ...(dimensions ? { enum: dimensions } : {}), }, } as const satisfies JSONSchema) -const filtersFn = (filterableDimensions: T) => ({ +const filtersFn = (filterableDimensions?: T) => ({ type: 'array', description: 'A list of filters to apply to the query', items: { @@ -400,7 +400,7 @@ const filtersFn = (filterableDimensions: T) => ({ properties: { field: { type: 'string', - enum: filterableDimensions, + ...(filterableDimensions ? { enum: filterableDimensions } : {}), }, operator: { type: 'string', @@ -426,7 +426,7 @@ const filtersFn = (filterableDimensions: T) => ({ properties: { field: { type: 'string', - enum: filterableDimensions, + ...(filterableDimensions ? { enum: filterableDimensions } : {}), }, operator: { type: 'string', @@ -443,6 +443,56 @@ const filtersFn = (filterableDimensions: T) => ({ }, } as const satisfies JSONSchema) +const platformFiltersFn = () => ({ + type: 'array', + description: 'A list of filters to apply to the platform query', + items: { + oneOf: [ + { + type: 'object', + description: 'In filter', + properties: { + field: { + type: 'string', + }, + operator: { + type: 'string', + }, + value: { + type: 'array', + items: { + type: ['string', 'number', 'null'], + }, + }, + }, + required: [ + 'field', + 'operator', + 'value', + ], + additionalProperties: false, + }, + { + type: 'object', + description: 'Empty filter', + properties: { + field: { + type: 'string', + }, + operator: { + type: 'string', + }, + }, + required: [ + 'field', + 'operator', + ], + additionalProperties: false, + }, + ], + }, +} as const satisfies JSONSchema) + export const apiUsageQuerySchema = { type: 'object', description: 'A query to launch at the advanced explore API', @@ -519,8 +569,27 @@ export const mcpUsageSchema = { additionalProperties: false, } as const satisfies JSONSchema +export const platformQuerySchema = { + type: 'object', + description: 'A query to launch at the platform dashboard API', + properties: { + datasource: { + type: 'string', + enum: [ + 'platform', + ], + }, + metrics: metricsFn(), + dimensions: dimensionsFn(), + filters: platformFiltersFn(), + ...baseQueryProperties, + }, + required: ['datasource'], + additionalProperties: false, +} as const satisfies JSONSchema + export const validDashboardQuery = { - anyOf: [apiUsageQuerySchema, basicQuerySchema, llmUsageSchema, mcpUsageSchema], + anyOf: [apiUsageQuerySchema, basicQuerySchema, llmUsageSchema, mcpUsageSchema, platformQuerySchema], } as const satisfies JSONSchema export type ValidDashboardQuery = FromSchemaWithOptions diff --git a/packages/analytics/analytics-utilities/src/filters.ts b/packages/analytics/analytics-utilities/src/filters.ts index 9dedf189df..a300d4c6d5 100644 --- a/packages/analytics/analytics-utilities/src/filters.ts +++ b/packages/analytics/analytics-utilities/src/filters.ts @@ -1,4 +1,4 @@ -import type { AllFilterableDimensionsAndMetrics, FilterDatasource } from './types' +import type { FilterDatasource } from './types' import { filterableAiExploreDimensions, filterableBasicExploreDimensions, @@ -9,8 +9,11 @@ import { } from './types' +/** + * @deprecated Use `useDatasourceConfigStore().getFieldDataSources` from `@kong-ui-public/analytics-config-store`. + */ export const getFieldDataSources = ( - dimension: AllFilterableDimensionsAndMetrics, + dimension: string, ): FilterDatasource[] => { const datasources: FilterDatasource[] = [] diff --git a/packages/analytics/analytics-utilities/src/types/explore/all.spec.ts b/packages/analytics/analytics-utilities/src/types/explore/all.spec.ts index 7f51430a23..889038aac4 100644 --- a/packages/analytics/analytics-utilities/src/types/explore/all.spec.ts +++ b/packages/analytics/analytics-utilities/src/types/explore/all.spec.ts @@ -36,6 +36,13 @@ describe('stripUnknownFilters', () => { value: ['foo'], } + // a filter that is valid for the platform datasource but not scoped elsewhere + const platformFilter = { + operator: 'in', + field: 'custom_platform_field', + value: ['foo'], + } + it.each([ ['basic', [basicFilter]], ['api_usage', [basicFilter, advancedFilter]], @@ -47,6 +54,11 @@ describe('stripUnknownFilters', () => { expect(result).toEqual(expected) }) + it('keeps all filters for platform', () => { + const result = stripUnknownFilters('platform', [unknownFilter, basicFilter, advancedFilter, llmFilter, mcpFilter, platformFilter]) + expect(result).toEqual([unknownFilter, basicFilter, advancedFilter, llmFilter, mcpFilter, platformFilter]) + }) + it('Keeps all filters if the datasource starts with "goap"', () => { // @ts-ignore these are the correct strings to use const result = stripUnknownFilters('goap_test', [unknownFilter, basicFilter, advancedFilter, llmFilter]) diff --git a/packages/analytics/analytics-utilities/src/types/explore/all.ts b/packages/analytics/analytics-utilities/src/types/explore/all.ts index 03ab538c83..dc4609c058 100644 --- a/packages/analytics/analytics-utilities/src/types/explore/all.ts +++ b/packages/analytics/analytics-utilities/src/types/explore/all.ts @@ -3,9 +3,10 @@ import { type AiExploreAggregations, type AiExploreFilterAll, type FilterableAiE import { type ExploreAggregations, type ExploreFilterAll, type FilterableExploreDimensions, filterableExploreDimensions } from './advanced' import { type FilterableRequestDimensions, type FilterableRequestMetrics, type FilterableRequestWildcardDimensions } from './requests' import { filterableMcpExploreDimensions, type FilterableMcpExploreDimensions, type McpExploreAggregations, type McpExploreFilterAll } from './mcp' +import { type PlatformExploreFilterAll } from './platform' export type AllAggregations = BasicExploreAggregations | AiExploreAggregations | ExploreAggregations | McpExploreAggregations -export type AllFilters = BasicExploreFilterAll | AiExploreFilterAll | ExploreFilterAll | McpExploreFilterAll +export type AllFilters = BasicExploreFilterAll | AiExploreFilterAll | ExploreFilterAll | McpExploreFilterAll | PlatformExploreFilterAll export type AllFilterableDimensionsAndMetrics = FilterableExploreDimensions | FilterableAiExploreDimensions | FilterableBasicExploreDimensions @@ -14,7 +15,7 @@ export type AllFilterableDimensionsAndMetrics = FilterableExploreDimensions | FilterableRequestMetrics | FilterableRequestWildcardDimensions -export const queryDatasources = ['basic', 'api_usage', 'llm_usage', 'agentic_usage'] as const +export const queryDatasources = ['basic', 'api_usage', 'llm_usage', 'agentic_usage', 'platform'] as const export type QueryDatasource = typeof queryDatasources[number] @@ -25,6 +26,7 @@ export interface FilterTypeMap extends Record { api_usage: ExploreFilterAll llm_usage: AiExploreFilterAll agentic_usage: McpExploreFilterAll + platform: PlatformExploreFilterAll } export const datasourceToFilterableDimensions: Record> = { @@ -32,9 +34,12 @@ export const datasourceToFilterableDimensions: Record(datasource: K, filters: AllFilters[]): Array => { if (datasource.startsWith('goap')) { // We currently can't determine the type for goap datasources as it could be @@ -42,6 +47,10 @@ export const stripUnknownFilters = + } + // Note: once we extend API request filters, this may need to look at more than just dimensions. // Note the cast; we could potentially try to derive the type, but it doesn't seem worth it. return filters.filter(f => datasourceToFilterableDimensions[datasource].has(f.field)) as Array diff --git a/packages/analytics/analytics-utilities/src/types/explore/index.ts b/packages/analytics/analytics-utilities/src/types/explore/index.ts index 31fcb75e42..5678ccce6d 100644 --- a/packages/analytics/analytics-utilities/src/types/explore/index.ts +++ b/packages/analytics/analytics-utilities/src/types/explore/index.ts @@ -3,6 +3,7 @@ export * from './basic' export * from './advanced' export * from './ai' export * from './requests' +export * from './platform' export * from './result' export * from './all' export * from './mcp' diff --git a/packages/analytics/analytics-utilities/src/types/explore/platform.ts b/packages/analytics/analytics-utilities/src/types/explore/platform.ts new file mode 100644 index 0000000000..b7d1230487 --- /dev/null +++ b/packages/analytics/analytics-utilities/src/types/explore/platform.ts @@ -0,0 +1,20 @@ +import type { BasicExploreQuery } from './basic' + +export interface PlatformExploreInFilterV2 { + operator: string + field: string + value: Array +} + +export interface PlatformExploreEmptyFilterV2 { + operator: string + field: string +} + +export type PlatformExploreFilterAll = PlatformExploreInFilterV2 | PlatformExploreEmptyFilterV2 + +export interface PlatformExploreQuery extends Omit { + metrics?: string[] + dimensions?: string[] + filters?: PlatformExploreFilterAll[] +} diff --git a/packages/analytics/analytics-utilities/src/types/query-bridge.ts b/packages/analytics/analytics-utilities/src/types/query-bridge.ts index ecd3ac7ae6..54d5f3d140 100644 --- a/packages/analytics/analytics-utilities/src/types/query-bridge.ts +++ b/packages/analytics/analytics-utilities/src/types/query-bridge.ts @@ -1,4 +1,4 @@ -import type { BasicExploreQuery, ExploreQuery, AiExploreQuery, ExploreResultV4 } from './explore' +import type { BasicExploreQuery, ExploreQuery, AiExploreQuery, ExploreResultV4, McpExploreQuery, PlatformExploreQuery } from './explore' import type { AnalyticsConfigV2 } from './analytics-config' import type { DatasourceConfig } from './datasource-config' import type { Component } from 'vue' @@ -18,7 +18,17 @@ export interface AiDatasourceQuery { query: AiExploreQuery } -export type DatasourceAwareQuery = BasicDatasourceQuery | AdvancedDatasourceQuery | AiDatasourceQuery +export interface McpDatasourceQuery { + datasource: 'agentic_usage' + query: McpExploreQuery +} + +export interface PlatformDatasourceQuery { + datasource: 'platform' + query: PlatformExploreQuery +} + +export type DatasourceAwareQuery = BasicDatasourceQuery | AdvancedDatasourceQuery | AiDatasourceQuery | McpDatasourceQuery | PlatformDatasourceQuery // All flags in this interface should be optional; defaults are as documented. export interface StaticConfig { diff --git a/packages/analytics/dashboard-renderer/src/components/DashboardRenderer.cy.ts b/packages/analytics/dashboard-renderer/src/components/DashboardRenderer.cy.ts index 7e484dcc47..5108d4ce11 100644 --- a/packages/analytics/dashboard-renderer/src/components/DashboardRenderer.cy.ts +++ b/packages/analytics/dashboard-renderer/src/components/DashboardRenderer.cy.ts @@ -5,6 +5,7 @@ import type { AnalyticsConfigV2, DashboardConfig, DatasourceAwareQuery, + DatasourceConfig, ExploreFilterAll, ExploreResultV4, TileConfig, @@ -38,6 +39,70 @@ interface MockOptions { renderEntityLink?: boolean } +const mockDatasourceConfig: DatasourceConfig[] = [ + { + name: 'api_usage', + showInUI: true, + fields: [ + { + name: 'api_product', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'control_plane', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'gateway_service', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'route', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'status_code', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + ], + }, +] + describe('', () => { beforeEach(() => { cy.viewport(1200, 1000) @@ -120,7 +185,7 @@ describe('', () => { return { queryFn: cy.spy(queryFn).as('fetcher'), configFn, - datasourceConfigFn: () => Promise.resolve([]), + datasourceConfigFn: () => Promise.resolve(mockDatasourceConfig), evaluateFeatureFlagFn, fetchComponent: opts?.renderEntityLink ? fetchComponentFn : undefined, } diff --git a/packages/analytics/dashboard-renderer/src/components/DashboardTile.cy.ts b/packages/analytics/dashboard-renderer/src/components/DashboardTile.cy.ts index 2deac9e45d..2e84814dfc 100644 --- a/packages/analytics/dashboard-renderer/src/components/DashboardTile.cy.ts +++ b/packages/analytics/dashboard-renderer/src/components/DashboardTile.cy.ts @@ -1,14 +1,157 @@ import DashboardTile from './DashboardTile.vue' +import TimeseriesChartRenderer from './TimeseriesChartRenderer.vue' import { INJECT_QUERY_PROVIDER } from '../constants' import type { DashboardRendererContextInternal } from '../types' -import { generateSingleMetricTimeSeriesData, type ExploreResultV4, type TileDefinition, EXPORT_RECORD_LIMIT, COUNTRIES } from '@kong-ui-public/analytics-utilities' +import { generateSingleMetricTimeSeriesData, type DatasourceConfig, type ExploreResultV4, type TileDefinition, EXPORT_RECORD_LIMIT, COUNTRIES } from '@kong-ui-public/analytics-utilities' import { setupPiniaTestStore } from '../stores/tests/setupPiniaTestStore' -import { useAnalyticsConfigStore } from '@kong-ui-public/analytics-config-store' -import { defineComponent, h, ref } from 'vue' +import { useAnalyticsConfigStore, useDatasourceConfigStore } from '@kong-ui-public/analytics-config-store' +import { flushPromises } from '@vue/test-utils' +import { defineComponent, h, nextTick, ref } from 'vue' const start = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString() const end = new Date().toISOString() +const datasourceConfigMock: DatasourceConfig[] = [ + { + name: 'api_usage', + showInUI: true, + timeRangeOptions: ['15m', '30m', '1h', '24h'], + fields: [ + { + name: 'api_product', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'control_plane', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'gateway_service', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'route', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'status_code', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'request_count', + showInUI: true, + aggregation: true, + group: false, + metricGroup: 'count', + }, + ], + }, + { + name: 'llm_usage', + showInUI: true, + timeRangeOptions: ['15m', '30m', '1h', '24h'], + fields: [ + { + name: 'ai_response_model', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'ai_provider', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'ai_request_model', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + ], + }, + { + name: 'requests', + showInUI: true, + timeRangeOptions: ['15m', '30m', '1h', '24h'], + fields: [ + { + name: 'status_code', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'number', + allowNewValues: false, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + { + name: 'route', + showInUI: true, + aggregation: false, + group: true, + filter: { + valueType: 'string', + allowNewValues: true, + operators: [{ type: 'multi-value', ops: ['in', 'not_in'] }], + }, + }, + ], + }, +] + describe('', () => { const mockTileDefinition: TileDefinition = { chart: { @@ -83,6 +226,7 @@ describe('', () => { const mockQueryProvider = { exploreBaseUrl: async () => 'http://test.com/explore', requestsBaseUrl: async () => 'http://test.com/requests', + datasourceConfigFn: () => Promise.resolve(datasourceConfigMock), evaluateFeatureFlagFn: () => true, queryFn: () => { return Promise.resolve( @@ -145,6 +289,11 @@ describe('', () => { const analyticsConfigStore = useAnalyticsConfigStore() // @ts-ignore - mocking just what we need for the test analyticsConfigStore.analyticsConfig = { analytics: { percentiles: true } } + const datasourceConfigStore = useDatasourceConfigStore() + // @ts-ignore - seeding the store for component tests + datasourceConfigStore.datasourceConfig = datasourceConfigMock + // @ts-ignore - clear any load errors from outside-component store access + datasourceConfigStore.datasourceConfigError = null }) it('should render tile with title', () => { @@ -360,6 +509,50 @@ describe('', () => { .should('not.have.string', 'response_model') }) + it('retains unknown goap context filters in zoom drilldown links', () => { + const context: DashboardRendererContextInternal = { + ...mockContext, + filters: [{ field: 'goap_only_field', operator: 'in', value: ['value'] }], + } + + cy.mount(DashboardTile, { + props: { + definition: cacheBustTile({ + ...mockTileDefinition, + query: { + ...mockTileDefinition.query, + datasource: 'goap_event_gateway', + }, + }), + context, + queryReady: true, + refreshCounter: 0, + tileId: '1', + }, + global: { + provide: { + [INJECT_QUERY_PROVIDER]: mockQueryProvider, + }, + }, + }).then(async ({ wrapper }) => { + await flushPromises() + + const chart = wrapper.findComponent(TimeseriesChartRenderer) + + expect(chart.exists()).to.equal(true) + + chart.vm.$emit('select-chart-range', { + type: 'absolute', + start: new Date(start), + end: new Date(end), + }) + + await nextTick() + + expect((chart.props('requestsLink') as { href: string }).href).to.contain('goap_only_field') + }) + }) + it('should show aged out warning when query granularity does not match saved granularity', () => { mount({ definition: { diff --git a/packages/analytics/dashboard-renderer/src/components/DashboardTile.spec.ts b/packages/analytics/dashboard-renderer/src/components/DashboardTile.spec.ts new file mode 100644 index 0000000000..02a7ba7db6 --- /dev/null +++ b/packages/analytics/dashboard-renderer/src/components/DashboardTile.spec.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import DashboardTile from './DashboardTile.vue' +import TimeseriesChartRenderer from './TimeseriesChartRenderer.vue' +import { INJECT_QUERY_PROVIDER } from '../constants' +import { setupPiniaTestStore } from '../stores/tests/setupPiniaTestStore' +import { useAnalyticsConfigStore } from '@kong-ui-public/analytics-config-store' +import type { DashboardRendererContextInternal } from '../types' +import type { TileDefinition } from '@kong-ui-public/analytics-utilities' + +vi.mock('./TimeseriesChartRenderer.vue', () => ({ + default: defineComponent({ + name: 'TimeseriesChartRenderer', + props: { + chartOptions: { + type: Object, + required: true, + }, + context: { + type: Object, + required: true, + }, + exploreLink: { + type: [String, Object], + default: undefined, + }, + height: { + type: Number, + required: true, + }, + query: { + type: Object, + required: true, + }, + queryReady: { + type: Boolean, + required: true, + }, + refreshCounter: { + type: Number, + required: true, + }, + requestsLink: { + type: [String, Object], + default: undefined, + }, + }, + emits: ['select-chart-range', 'zoom-time-range'], + setup(props) { + return () => h('div', { + 'data-testid': 'timeseries-renderer-stub', + 'data-explore-link': props.exploreLink === undefined ? 'undefined' : String(props.exploreLink), + 'data-requests-link': props.requestsLink === undefined ? 'undefined' : String(props.requestsLink), + }) + }, + }), +})) + +const mockQueryProvider = { + exploreBaseUrl: async () => 'http://test.com/explore', + requestsBaseUrl: async () => 'http://test.com/requests', + datasourceConfigFn: () => Promise.resolve([]), + evaluateFeatureFlagFn: () => true, +} + +const mockContext: DashboardRendererContextInternal = { + filters: [], + timeSpec: { + type: 'relative', + time_range: '15m', + }, + editable: false, + tz: '', + refreshInterval: 0, + zoomable: false, +} + +const baseDefinition: TileDefinition = { + chart: { + type: 'timeseries_line', + chart_title: 'Test Chart', + }, + query: { + datasource: 'api_usage', + metrics: ['request_count'], + dimensions: ['time'], + filters: [], + }, +} + +const mountTile = (datasource: TileDefinition['query']['datasource']) => { + const definition = { + ...baseDefinition, + query: { + ...baseDefinition.query, + datasource, + }, + } as TileDefinition + + return mount(DashboardTile, { + props: { + definition, + context: mockContext, + queryReady: true, + refreshCounter: 0, + tileId: '1', + }, + shallow: true, + global: { + provide: { + [INJECT_QUERY_PROVIDER]: mockQueryProvider, + }, + stubs: { + TimeseriesChartRenderer: false, + }, + }, + }) +} + +describe(' zoom requests drilldown', () => { + beforeEach(() => { + setupPiniaTestStore() + const analyticsConfigStore = useAnalyticsConfigStore() + analyticsConfigStore.analyticsConfig = { analytics: { percentiles: true } } as any + }) + + it('does not populate requests zoom actions for platform tiles', async () => { + const wrapper = mountTile('platform') + await flushPromises() + + const renderer = wrapper.findComponent(TimeseriesChartRenderer) + expect(renderer.exists()).toBe(true) + expect(renderer.props('requestsLink')).toBeUndefined() + + renderer.vm.$emit('select-chart-range', { + type: 'absolute', + start: new Date('2024-01-01T00:00:00Z'), + end: new Date('2024-01-01T01:00:00Z'), + }) + + await nextTick() + + expect(wrapper.findComponent(TimeseriesChartRenderer).props('requestsLink')).toBeUndefined() + expect(wrapper.findComponent(TimeseriesChartRenderer).props('exploreLink')).toBeDefined() + }) + + it('still populates requests zoom actions for api_usage tiles', async () => { + const wrapper = mountTile('api_usage') + await flushPromises() + + const renderer = wrapper.findComponent(TimeseriesChartRenderer) + expect(renderer.exists()).toBe(true) + expect(renderer.props('requestsLink')).toMatchObject({ href: '' }) + + renderer.vm.$emit('select-chart-range', { + type: 'absolute', + start: new Date('2024-01-01T00:00:00Z'), + end: new Date('2024-01-01T01:00:00Z'), + }) + + await nextTick() + + expect((wrapper.findComponent(TimeseriesChartRenderer).props('requestsLink') as { href?: string } | undefined)?.href).toContain('http://test.com/requests?q=') + }) +}) diff --git a/packages/analytics/dashboard-renderer/src/components/DashboardTile.vue b/packages/analytics/dashboard-renderer/src/components/DashboardTile.vue index 0f94d00bad..5b88378bc9 100644 --- a/packages/analytics/dashboard-renderer/src/components/DashboardTile.vue +++ b/packages/analytics/dashboard-renderer/src/components/DashboardTile.vue @@ -176,12 +176,12 @@ import type { DashboardTileType, ExploreQuery, ExploreResultV4, - FilterDatasource, + AllFilters, TileDefinition, } from '@kong-ui-public/analytics-utilities' import { type Component, computed, defineAsyncComponent, inject, nextTick, readonly, ref, toRef, watch } from 'vue' -import { formatTime, TimePeriods, getFieldDataSources, msToGranularity, TIMEFRAME_LOOKUP, EXPORT_RECORD_LIMIT } from '@kong-ui-public/analytics-utilities' +import { formatTime, TimePeriods, msToGranularity, TIMEFRAME_LOOKUP, EXPORT_RECORD_LIMIT } from '@kong-ui-public/analytics-utilities' import { CsvExportModal } from '@kong-ui-public/analytics-chart' import '@kong-ui-public/analytics-chart/dist/style.css' import '@kong-ui-public/analytics-metric-provider/dist/style.css' @@ -192,6 +192,8 @@ import TimeseriesChartRenderer from './TimeseriesChartRenderer.vue' import GoldenSignalsRenderer from './GoldenSignalsRenderer.vue' import TopNTableRenderer from './TopNTableRenderer.vue' import composables from '../composables' +import { useDatasourceConfigStore } from '@kong-ui-public/analytics-config-store' +import { storeToRefs } from 'pinia' import { KUI_COLOR_TEXT_NEUTRAL, KUI_ICON_SIZE_40, KUI_ICON_SIZE_60, KUI_ICON_SIZE_20, KUI_SPACE_70 } from '@kong/design-tokens' import { MoreIcon, EditIcon, WarningIcon, ProgressIcon, RefreshIcon } from '@kong/icons' @@ -232,6 +234,8 @@ const emit = defineEmits<{ const GeoMapRendererAsync = defineAsyncComponent(() => import('./GeoMapRenderer.vue')) const queryBridge: AnalyticsBridge | undefined = inject(INJECT_QUERY_PROVIDER) +const datasourceConfigStore = useDatasourceConfigStore() +const { stripUnknownFilters } = storeToRefs(datasourceConfigStore) const { i18n } = composables.useI18n() const chartData = ref() const exportState = ref({ status: 'loading' }) @@ -411,15 +415,15 @@ const agedOutWarning = computed(() => { * @returns Array of scoped filter objects to a datasource */ const datasourceScopedFilters = computed(() => { - const filters = [...props.context.filters, ...props.definition.query.filters ?? []] - + const filters = [...props.context.filters, ...props.definition.query.filters ?? []] as AllFilters[] + const metrics = props.definition.query.metrics // TODO: default to api_usage until datasource is made required const datasource = props.definition?.query?.datasource ?? 'api_usage' - return filters.filter(f => { - const possibleSources = getFieldDataSources(f.field) - - return possibleSources.some((ds: FilterDatasource) => datasource === ds) + return stripUnknownFilters.value({ + datasource, + filters, + metrics, }) }) @@ -501,11 +505,10 @@ const onBoundsChange = (e: Array<[number, number]>) => { const onSelectChartRange = (newTimeRange: AbsoluteTimeRangeV4) => { const filters = datasourceScopedFilters.value - const requestsQuery = buildRequestsQueryZoomActions(newTimeRange, filters) const exploreQuery = buildExploreQuery(newTimeRange, filters) - requestsLinkZoomActions.value = canGenerateRequestsLink.value ? { href: buildRequestLink(requestsQuery) } : undefined exploreLinkZoomActions.value = canGenerateExploreLink.value ? { href: buildExploreLink(exploreQuery as ExploreQuery | AiExploreQuery) } : undefined + requestsLinkZoomActions.value = canGenerateRequestsLink.value ? { href: buildRequestLink(buildRequestsQueryZoomActions(newTimeRange, filters)) } : undefined } defineExpose({ getExportData }) diff --git a/packages/analytics/dashboard-renderer/src/components/QueryDataProvider.spec.ts b/packages/analytics/dashboard-renderer/src/components/QueryDataProvider.spec.ts index 8b3184e6f7..7bfd207d6b 100644 --- a/packages/analytics/dashboard-renderer/src/components/QueryDataProvider.spec.ts +++ b/packages/analytics/dashboard-renderer/src/components/QueryDataProvider.spec.ts @@ -4,6 +4,7 @@ import { flushPromises, mount } from '@vue/test-utils' import QueryDataProvider from './QueryDataProvider.vue' import { INJECT_QUERY_PROVIDER } from '../constants' +import { useDatasourceConfigStore } from '@kong-ui-public/analytics-config-store' const swrvData = ref(undefined) const swrvError = ref(null) @@ -27,9 +28,16 @@ vi.mock('swrv', () => ({ }), })) +vi.mock('@kong-ui-public/analytics-config-store', () => ({ + useDatasourceConfigStore: vi.fn(), +})) + describe('QueryDataProvider', () => { beforeEach(() => { resetSwrvState() + vi.mocked(useDatasourceConfigStore).mockReturnValue({ + stripUnknownFilters: ({ filters }: { filters: any[] }) => filters, + } as any) }) it('shows fallback error message when queryError is null', async () => { diff --git a/packages/analytics/dashboard-renderer/src/composables/useContextLinks.spec.ts b/packages/analytics/dashboard-renderer/src/composables/useContextLinks.spec.ts index 7f34373b59..9382f762c1 100644 --- a/packages/analytics/dashboard-renderer/src/composables/useContextLinks.spec.ts +++ b/packages/analytics/dashboard-renderer/src/composables/useContextLinks.spec.ts @@ -1,25 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { computed } from 'vue' +import { computed, ref } from 'vue' import { flushPromises, mount } from '@vue/test-utils' import useContextLinks from './useContextLinks' import { setupPiniaTestStore } from '../stores/tests/setupPiniaTestStore' +import { useDatasourceConfigStore } from '@kong-ui-public/analytics-config-store' const ONE_HOUR_MS = 3600000 const ONE_DAY_MS = 86400000 vi.mock('@kong-ui-public/analytics-utilities', () => ({ - getFieldDataSources: vi.fn((field: string) => { - if (field === 'gateway_service') { - return ['api_usage'] - } - if (field === 'ai_provider') { - return ['llm_usage'] - } - if (field === 'shared_field') { - return ['api_usage', 'llm_usage'] - } - return [] - }), msToGranularity: vi.fn((ms: number) => { if (ms === ONE_HOUR_MS) return 'hourly' if (ms === ONE_DAY_MS) return 'daily' @@ -30,9 +19,35 @@ vi.mock('@kong-ui-public/analytics-utilities', () => ({ const analyticsConfig = { analytics: true, percentiles: true } vi.mock('@kong-ui-public/analytics-config-store', () => ({ + useDatasourceConfigStore: vi.fn(), useAnalyticsConfigStore: vi.fn(() => analyticsConfig), })) +const mockStripUnknownFilters = ({ + datasource, + filters, +}: { + datasource: string + filters: Array<{ field: string }> +}) => { + if (datasource === 'platform' || datasource.startsWith('goap')) { + return filters + } + + return filters.filter(({ field }) => { + if (field === 'gateway_service') { + return datasource === 'api_usage' + } + if (field === 'ai_provider') { + return datasource === 'llm_usage' + } + if (field === 'shared_field') { + return datasource === 'api_usage' || datasource === 'llm_usage' + } + return false + }) +} + const makeFilter = (field: string) => ({ field, operator: 'in', value: ['x'] }) const absoluteTimeRange = { @@ -60,6 +75,7 @@ function mountComposable({ requestsBase = '#requests', chartType = 'line', datasource = 'api_usage', + metrics = ['request_count'], explicitGranularity, queryFilters = [], contextFilters = [], @@ -72,6 +88,7 @@ function mountComposable({ requestsBase?: string chartType?: string datasource?: string + metrics?: string[] explicitGranularity?: string | undefined queryFilters?: any[] contextFilters?: any[] @@ -96,7 +113,7 @@ function mountComposable({ chart: { type: chartType }, query: { datasource, - metrics: ['request_count'], + metrics, dimensions: ['gateway_service'], filters: queryFilters, granularity: explicitGranularity, @@ -132,6 +149,40 @@ describe('useContextLinks', () => { vi.clearAllMocks() analyticsConfig.analytics = true analyticsConfig.percentiles = true + vi.mocked(useDatasourceConfigStore).mockReturnValue({ + stripUnknownFilters: ref(mockStripUnknownFilters), + loading: ref(false), + isReady: vi.fn().mockResolvedValue(undefined), + } as any) + }) + + it('waits for datasource config readiness before generating links', async () => { + let resolveReady!: () => void + const loading = ref(true) + const ready = new Promise((resolve) => { + resolveReady = () => { + loading.value = false + resolve() + } + }) + + vi.mocked(useDatasourceConfigStore).mockReturnValueOnce({ + stripUnknownFilters: ref(mockStripUnknownFilters), + loading, + isReady: vi.fn(() => ready), + } as any) + + const { wrapper } = mountComposable({}) + await flushPromises() + + expect(wrapper.vm.exploreLinkKebabMenu).toBe('') + expect(wrapper.vm.requestsLinkKebabMenu).toBe('') + + resolveReady() + await flushPromises() + + expect(wrapper.vm.exploreLinkKebabMenu).toContain('#explore?') + expect(wrapper.vm.requestsLinkKebabMenu).toContain('#requests?') }) it('builds explore link with datasource-scoped filters and explicit granularity', async () => { @@ -161,6 +212,27 @@ describe('useContextLinks', () => { expect(params.get('c')).toBe('line') }) + it('forwards metrics to filter stripping when generating dashboard links', async () => { + const stripUnknownFiltersSpy = vi.fn(mockStripUnknownFilters) + vi.mocked(useDatasourceConfigStore).mockReturnValueOnce({ + stripUnknownFilters: ref(stripUnknownFiltersSpy), + loading: ref(false), + isReady: vi.fn().mockResolvedValue(undefined), + } as any) + + const { wrapper } = mountComposable({ + metrics: ['request_count', 'request_size'], + }) + + await flushPromises() + void wrapper.vm.exploreLinkKebabMenu + + expect(stripUnknownFiltersSpy).toHaveBeenCalledWith(expect.objectContaining({ + datasource: 'api_usage', + metrics: ['request_count', 'request_size'], + })) + }) + it('falls back to chartData granularity via msToGranularity when query.granularity not set', async () => { const { wrapper } = mountComposable({ explicitGranularity: undefined, @@ -193,6 +265,32 @@ describe('useContextLinks', () => { expect(params.get('d')).toBe('api_usage') }) + it('preserves platform for explore link', async () => { + const { wrapper } = mountComposable({ + datasource: 'platform', + queryFilters: [makeFilter('gateway_service')], + }) + await flushPromises() + + expect(wrapper.vm.canGenerateExploreLink).toBe(true) + + const params = new URLSearchParams((wrapper.vm.exploreLinkKebabMenu as string).split('?')[1]) + expect(params.get('d')).toBe('platform') + }) + + it('does not generate requests drilldown for platform tiles', async () => { + const { wrapper } = mountComposable({ + datasource: 'platform', + queryFilters: [makeFilter('shared_field')], + }) + await flushPromises() + + expect(wrapper.vm.canGenerateRequestsLink).toBe(false) + expect(wrapper.vm.requestsLinkKebabMenu).toBe('') + expect(wrapper.vm.requestsLinkZoomActions).toBeUndefined() + expect(wrapper.vm.exploreLinkKebabMenu).toContain('#explore?') + }) + it('falls back to api_usage when query datasource is not provided', async () => { const { wrapper } = mountComposable({ datasource: undefined }) await flushPromises() @@ -211,7 +309,7 @@ describe('useContextLinks', () => { }) it('builds explore link for expected datasources', async () => { - const datasources = ['api_usage', 'basic', 'llm_usage', 'agentic_usage', undefined] + const datasources = ['api_usage', 'basic', 'llm_usage', 'agentic_usage', 'platform', undefined] for (const ds of datasources) { const { wrapper } = mountComposable({ @@ -304,6 +402,20 @@ describe('useContextLinks', () => { expect(parsed.timeframe.end).toBeUndefined() }) + it('keeps unknown datasource filters when building requests link', async () => { + const { wrapper } = mountComposable({ + datasource: 'goap_event_gateway', + contextFilters: [{ field: 'goap_only_field', operator: 'in', value: ['x'] }], + }) + await flushPromises() + + const parsed = JSON.parse(decodeURIComponent((wrapper.vm.requestsLinkKebabMenu as string).split('=')[1])) + + expect(parsed.filter).toEqual([ + { field: 'goap_only_field', operator: 'in', value: ['x'] }, + ]) + }) + it('buildRequestsQueryZoomActions uses provided absolute range start/end directly', async () => { const { wrapper } = mountComposable({ timeRange: absoluteTimeRange, diff --git a/packages/analytics/dashboard-renderer/src/composables/useContextLinks.ts b/packages/analytics/dashboard-renderer/src/composables/useContextLinks.ts index 1ba722b580..a8de97f3da 100644 --- a/packages/analytics/dashboard-renderer/src/composables/useContextLinks.ts +++ b/packages/analytics/dashboard-renderer/src/composables/useContextLinks.ts @@ -1,8 +1,9 @@ import { computed, onMounted, ref, watch } from 'vue' -import { getFieldDataSources, msToGranularity } from '@kong-ui-public/analytics-utilities' -import { useAnalyticsConfigStore } from '@kong-ui-public/analytics-config-store' +import { msToGranularity } from '@kong-ui-public/analytics-utilities' +import { useAnalyticsConfigStore, useDatasourceConfigStore } from '@kong-ui-public/analytics-config-store' +import { storeToRefs } from 'pinia' import type { DeepReadonly, Ref } from 'vue' -import type { AiExploreAggregations, AiExploreQuery, AllFilters, ExploreAggregations, ExploreQuery, ExploreResultV4, FilterDatasource, QueryableAiExploreDimensions, QueryableExploreDimensions, TimeRangeV4 } from '@kong-ui-public/analytics-utilities' +import type { AiExploreAggregations, AiExploreQuery, AllFilters, ExploreAggregations, ExploreQuery, ExploreResultV4, QueryableAiExploreDimensions, QueryableExploreDimensions, TimeRangeV4 } from '@kong-ui-public/analytics-utilities' import type { AnalyticsBridge, TileDefinition } from '@kong-ui-public/analytics-utilities' import type { DashboardRendererContextInternal } from '../types' import type { ExternalLink } from '@kong-ui-public/analytics-chart' @@ -36,6 +37,8 @@ export default function useContextLinks( const exploreLinkZoomActions = ref(undefined) const analyticsConfigStore = useAnalyticsConfigStore() + const datasourceConfigStore = useDatasourceConfigStore() + const { stripUnknownFilters, loading: datasourceConfigLoading } = storeToRefs(datasourceConfigStore) onMounted(async () => { // Since this is async, it can't be in the `computed`. Just check once, when the component mounts. @@ -58,23 +61,22 @@ export default function useContextLinks( return true }) - const canGenerateRequestsLink = computed(() => requestsBaseUrl.value && definition.value.query && definition.value.query.datasource !== 'llm_usage' && isAdvancedAnalytics.value) - const canGenerateExploreLink = computed(() => exploreBaseUrl.value && definition.value.query && EXPLORE_DATASOURCES.includes(definition.value.query.datasource) && isAdvancedAnalytics.value) + const canGenerateRequestsLink = computed(() => requestsBaseUrl.value && definition.value.query && definition.value.query.datasource !== 'llm_usage' && definition.value.query.datasource !== 'platform' && isAdvancedAnalytics.value && !datasourceConfigLoading.value) + const canGenerateExploreLink = computed(() => exploreBaseUrl.value && definition.value.query && EXPLORE_DATASOURCES.includes(definition.value.query.datasource as any) && isAdvancedAnalytics.value && !datasourceConfigLoading.value) const chartDataGranularity = computed(() => { return chartData.value ? msToGranularity(chartData.value.meta.granularity_ms) : undefined }) const datasourceScopedFilters = computed(() => { - const filters = [...context.value.filters, ...definition.value.query.filters ?? []] - - // TODO: default to api_usage until datasource is made required + const filters = [...context.value.filters, ...definition.value.query.filters ?? []] as AllFilters[] + const metrics = definition.value.query.metrics const datasource = definition.value.query?.datasource ?? 'api_usage' - return filters.filter(f => { - const possibleSources = getFieldDataSources(f.field) - - return possibleSources.some((ds: FilterDatasource) => datasource === ds) + return stripUnknownFilters.value({ + datasource, + filters, + metrics, }) }) @@ -85,7 +87,7 @@ export default function useContextLinks( return '' } - const filters = datasourceScopedFilters.value + const filters = datasourceScopedFilters.value as AllFilters[] const timeRange = definition.value.query.time_range as TimeRangeV4 || context.value.timeSpec const exploreQuery = buildExploreQuery(timeRange, filters) return buildExploreLink(exploreQuery) @@ -96,7 +98,7 @@ export default function useContextLinks( return '' } - const filters = datasourceScopedFilters.value + const filters = datasourceScopedFilters.value as AllFilters[] const requestsQuery = buildRequestsQueryKebabMenu( definition.value.query.time_range as TimeRangeV4 || context.value.timeSpec, @@ -156,8 +158,10 @@ export default function useContextLinks( return '' } - // If the datasource is 'basic' or not provided, fallback to api_usage - const datasource = ['api_usage', 'llm_usage', 'agentic_usage'].includes(definition.value.query.datasource) ? definition.value.query.datasource : 'api_usage' + // `basic` and unset datasources are issued through api_usage. + const datasource = definition.value.query.datasource && definition.value.query.datasource !== 'basic' + ? definition.value.query.datasource + : 'api_usage' return `${exploreBaseUrl.value}?q=${JSON.stringify(exploreQuery)}&d=${datasource}&c=${definition.value.chart.type}` } diff --git a/packages/analytics/dashboard-renderer/src/composables/useIssueQuery.spec.ts b/packages/analytics/dashboard-renderer/src/composables/useIssueQuery.spec.ts new file mode 100644 index 0000000000..193dc5ce79 --- /dev/null +++ b/packages/analytics/dashboard-renderer/src/composables/useIssueQuery.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { mount } from '@vue/test-utils' +import useIssueQuery from './useIssueQuery' +import { INJECT_QUERY_PROVIDER } from '../constants' +import type { AllFilters, AnalyticsBridge } from '@kong-ui-public/analytics-utilities' +import { useDatasourceConfigStore } from '@kong-ui-public/analytics-config-store' + +vi.mock('@kong-ui-public/analytics-config-store', () => ({ + useDatasourceConfigStore: vi.fn(), +})) + +const mountComposable = (queryBridge: AnalyticsBridge) => { + const wrapper = mount(defineComponent({ + setup() { + return useIssueQuery() + }, + template: '
', + }), { + global: { + provide: { + [INJECT_QUERY_PROVIDER]: queryBridge, + }, + }, + }) + + return wrapper +} + +describe('useIssueQuery', () => { + const mockStore = { + isReady: vi.fn().mockResolvedValue(undefined), + stripUnknownFilters: ({ filters }: { filters: AllFilters[] } ) => filters, + } as any + + const context: any = { + filters: [ + { + field: 'gateway_service', + operator: 'in', + value: ['example-service'], + }, + ], + timeSpec: { + type: 'relative', + time_range: '15m', + }, + editable: false, + tz: 'UTC', + refreshInterval: 0, + zoomable: false, + } + + it('passes through unknown datasources as-is', async () => { + vi.mocked(useDatasourceConfigStore).mockReturnValue(mockStore) + const queryFn = vi.fn().mockResolvedValue({}) + const wrapper = mountComposable({ + queryFn, + } as any) + + await wrapper.vm.issueQuery({ + datasource: 'custom_datasource', + metrics: [], + dimensions: [], + filters: [], + } as any, context) + + expect(queryFn).toHaveBeenCalledOnce() + expect(queryFn.mock.calls[0][0]).toMatchObject({ + datasource: 'custom_datasource', + query: { + filters: [ + { + field: 'gateway_service', + operator: 'in', + value: ['example-service'], + }, + ], + }, + }) + }) + + it('keeps the basic fallback when datasource is omitted', async () => { + vi.mocked(useDatasourceConfigStore).mockReturnValue(mockStore) + const queryFn = vi.fn().mockResolvedValue({}) + const wrapper = mountComposable({ + queryFn, + } as any) + + await wrapper.vm.issueQuery({ + metrics: [], + dimensions: [], + filters: [], + } as any, context) + + expect(queryFn).toHaveBeenCalledOnce() + expect(queryFn.mock.calls[0][0]).toMatchObject({ + datasource: 'basic', + }) + }) +}) diff --git a/packages/analytics/dashboard-renderer/src/composables/useIssueQuery.ts b/packages/analytics/dashboard-renderer/src/composables/useIssueQuery.ts index 48cb617299..657d203ad0 100644 --- a/packages/analytics/dashboard-renderer/src/composables/useIssueQuery.ts +++ b/packages/analytics/dashboard-renderer/src/composables/useIssueQuery.ts @@ -1,30 +1,16 @@ import { type AllFilters, type AnalyticsBridge, type DatasourceAwareQuery, type ExploreFilterAll, type ExploreQuery, - type FilterTypeMap, - type QueryDatasource, stripUnknownFilters, type TimeRangeV4, + type TimeRangeV4, type ValidDashboardQuery, } from '@kong-ui-public/analytics-utilities' +import { useDatasourceConfigStore } from '@kong-ui-public/analytics-config-store' import type { DashboardRendererContextInternal } from '../types' import { inject, onUnmounted } from 'vue' import { INJECT_QUERY_PROVIDER } from '../constants' -const deriveFilters = (datasource: D, queryFilters: Array | undefined, contextFilters: AllFilters[]): Array => { - const mergedFilters: Array = [] - - if (queryFilters) { - // The filters from the query should be safe -- as in, validated to be compatible - // with the chosen endpoint. - mergedFilters.push(...queryFilters) - } - - // The contextual filters may not be compatible and need to be pruned. - mergedFilters.push(...stripUnknownFilters(datasource, contextFilters)) - - return mergedFilters -} - export default function useIssueQuery() { const queryBridge: AnalyticsBridge | undefined = inject(INJECT_QUERY_PROVIDER) + const datasourceConfigStore = useDatasourceConfigStore() // Ensure that any pending requests are canceled on unmount. const abortController = new AbortController() @@ -33,12 +19,13 @@ export default function useIssueQuery() { abortController.abort() }) - const issueQuery = async (query: ValidDashboardQuery, context: DashboardRendererContextInternal, limitOverride?: number) => { if (!queryBridge) { throw new Error('Query bridge is not defined') } + await datasourceConfigStore.isReady() + const { datasource: originalDatasource, limit, @@ -47,7 +34,19 @@ export default function useIssueQuery() { const datasource = originalDatasource || 'basic' - const mergedFilters = deriveFilters(datasource, query.filters as Array, context.filters) + const mergedFilters: AllFilters[] = [] + + if (query.filters) { + // The filters from the query should be safe -- as in, validated to be compatible + // with the chosen endpoint. + mergedFilters.push(...query.filters as AllFilters[]) + } + + // The contextual filters may not be compatible and need to be pruned. + mergedFilters.push(...datasourceConfigStore.stripUnknownFilters({ + datasource, + filters: context.filters, + })) // TODO: the cast is necessary because TimeRangeV4 specifies date objects for absolute time ranges. // If they're coming from a definition, they're strings; should clean this up as part of the dashboard type work. @@ -68,15 +67,15 @@ export default function useIssueQuery() { // TODO: similar to other places, consider adding a type guard to ensure the query // matches the datasource. Currently, this block effectively pretends all queries // are advanced in order to make the types work out. - const mergedQuery: DatasourceAwareQuery = { - datasource: datasource as 'api_usage', + const mergedQuery = { + datasource, query: { ...rest as ExploreQuery, time_range, filters: mergedFilters as ExploreFilterAll[], limit: limitOverride ?? limit, }, - } + } as DatasourceAwareQuery return queryBridge.queryFn(mergedQuery, abortController) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1f4eebb7c..95e9ea5cd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -388,6 +388,9 @@ importers: '@kong/design-tokens': specifier: 1.20.0 version: 1.20.0 + ajv: + specifier: ^8.17.1 + version: 8.18.0 json-schema-to-ts: specifier: ^3.1.1 version: 3.1.1