diff --git a/src/platform/packages/shared/kbn-esql-utils/index.ts b/src/platform/packages/shared/kbn-esql-utils/index.ts index 1ed1f4e079872..8a51b920d34d1 100644 --- a/src/platform/packages/shared/kbn-esql-utils/index.ts +++ b/src/platform/packages/shared/kbn-esql-utils/index.ts @@ -9,6 +9,7 @@ export { getESQLAdHocDataview, + getESQLTimeFieldFromQuery, getIndexPatternFromESQLQuery, getSourceCommandFromESQLQuery, hasTransformationalCommand, diff --git a/src/platform/packages/shared/kbn-esql-utils/src/index.ts b/src/platform/packages/shared/kbn-esql-utils/src/index.ts index ce5903ae9f9fc..795eaed78bc4f 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/index.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/index.ts @@ -8,6 +8,7 @@ */ export { getESQLAdHocDataview, getIndexForESQLQuery } from './utils/get_esql_adhoc_dataview'; +export { getESQLTimeFieldFromQuery } from './utils/get_esql_time_field_from_query'; export { getInitialESQLQuery } from './utils/get_initial_esql_query'; export { getESQLWithSafeLimit } from './utils/get_esql_with_safe_limit'; export { diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/get_esql_adhoc_dataview.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/get_esql_adhoc_dataview.ts index 101bdbf6bd312..7ed16575f376e 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/utils/get_esql_adhoc_dataview.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/get_esql_adhoc_dataview.ts @@ -9,19 +9,9 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { HttpStart } from '@kbn/core/public'; import { ESQL_TYPE } from '@kbn/data-view-utils'; -import { LRUCache } from 'lru-cache'; -import { - type ESQLSourceResult, - SOURCES_AUTOCOMPLETE_ROUTE, - TIMEFIELD_ROUTE, -} from '@kbn/esql-types'; +import { type ESQLSourceResult, SOURCES_AUTOCOMPLETE_ROUTE } from '@kbn/esql-types'; import { getIndexPatternFromESQLQuery } from './get_index_pattern_from_query'; - -// Caches the in-flight or resolved TIMEFIELD_ROUTE promise by query. -// Storing the Promise (not the resolved value) deduplicates concurrent calls: -// if multiple callers request the same query before the first resolves, -// they all await the same promise instead of each firing a separate HTTP request. -const timeFieldCache = new LRUCache>({ max: 100 }); +import { getESQLTimeFieldFromQuery } from './get_esql_time_field_from_query'; // uses browser sha256 method with fallback if unavailable async function sha256(str: string) { @@ -83,22 +73,7 @@ export async function getESQLAdHocDataview({ // optional http service to use to fetch the time field, if needed http?: HttpStart; }) { - let timeFieldName: string | undefined; - if (timeFieldCache.has(query)) { - timeFieldName = await timeFieldCache.get(query); - } else if (http) { - const pendingRequest = http - .post(TIMEFIELD_ROUTE, { body: JSON.stringify({ query }) }) - .then((response) => (response as { timeField?: string } | undefined)?.timeField) - .catch((error) => { - // eslint-disable-next-line no-console - console.error('Failed to fetch the timefield', error); - timeFieldCache.delete(query); - return undefined; - }); - timeFieldCache.set(query, pendingRequest); - timeFieldName = await pendingRequest; - } + const timeFieldName = await getESQLTimeFieldFromQuery({ query, http }); const indexPattern = getIndexPatternFromESQLQuery(query); const prefix = options?.idPrefix ?? 'esql'; diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/get_esql_time_field_from_query.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/get_esql_time_field_from_query.ts new file mode 100644 index 0000000000000..b7cdad120a140 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/get_esql_time_field_from_query.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import type { HttpStart } from '@kbn/core/public'; +import { TIMEFIELD_ROUTE } from '@kbn/esql-types'; +import { LRUCache } from 'lru-cache'; + +// Caches the in-flight or resolved TIMEFIELD_ROUTE promise by query. +// Storing the Promise (not the resolved value) deduplicates concurrent calls: +// if multiple callers request the same query before the first resolves, +// they all await the same promise instead of each firing a separate HTTP request. +const timeFieldCache = new LRUCache>({ max: 100 }); + +/** + * Resolves the default time field for an ES|QL query by calling the timefield API. + * + * When `http` is omitted, returns `undefined` (unless a prior successful request + * for the same query left a value in the in-memory cache). + * + * Concurrent requests for the same query share one HTTP request via an LRU-backed promise cache. + */ +export async function getESQLTimeFieldFromQuery({ + query, + http, +}: { + query: string; + http?: HttpStart; +}): Promise { + const cached = timeFieldCache.get(query); + if (cached !== undefined) { + return cached; + } + if (!http) { + return undefined; + } + const pendingRequest = http + .post(TIMEFIELD_ROUTE, { body: JSON.stringify({ query }) }) + .then((response) => (response as { timeField?: string } | undefined)?.timeField) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('Failed to fetch the timefield', error); + timeFieldCache.delete(query); + return undefined; + }); + timeFieldCache.set(query, pendingRequest); + return pendingRequest; +} diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts b/src/platform/plugins/shared/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts index 94b47b97a2a54..dee4fc0dd2e19 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts @@ -43,6 +43,8 @@ describe('extendedDataLayerConfig', () => { type: 'extendedDataLayer', layerType: LayerTypes.DATA, ...fullArgs, + xScaleType: 'time', // xAccessor `c` is a date type column + isHistogram: true, table: data, showLines: true, }); diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts b/src/platform/plugins/shared/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts index a6dcf3fcd6761..9e9d0c3d7faa4 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts @@ -10,7 +10,7 @@ import type { ExpressionValueVisDimension } from '@kbn/chart-expressions-common'; import { validateAccessor } from '@kbn/chart-expressions-common'; import type { ExtendedDataLayerArgs, ExtendedDataLayerFn } from '../types'; -import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants'; +import { EXTENDED_DATA_LAYER, LayerTypes, XScaleTypes } from '../constants'; import { getAccessors, normalizeTable, getShowLines } from '../helpers'; import { validateLinesVisibilityForChartType, @@ -41,9 +41,16 @@ export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, const showLines = getShowLines(args); + const xScaleType = + table.columns.find((column) => column.id === accessors.xAccessor)?.meta.type === 'date' + ? XScaleTypes.TIME + : args.xScaleType; + return { type: EXTENDED_DATA_LAYER, ...args, + xScaleType, + isHistogram: xScaleType === XScaleTypes.TIME || args.isHistogram, layerType: LayerTypes.DATA, ...accessors, table: normalizedTable, diff --git a/x-pack/platform/plugins/shared/lens/public/data_views_service/loader.test.ts b/x-pack/platform/plugins/shared/lens/public/data_views_service/loader.test.ts index 7b6fbf0eed6eb..7eeac2cf1acff 100644 --- a/x-pack/platform/plugins/shared/lens/public/data_views_service/loader.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/data_views_service/loader.test.ts @@ -8,7 +8,7 @@ import type { DataViewsContract, DataViewField, DataViewSpec } from '@kbn/data-views-plugin/public'; import type { TextBasedPersistedState } from '@kbn/lens-common'; import type { HttpStart } from '@kbn/core/public'; -import { getESQLAdHocDataview } from '@kbn/esql-utils'; +import { getESQLTimeFieldFromQuery } from '@kbn/esql-utils'; import { ensureIndexPattern, ensureESQLTimeFieldOnAdHocDataViews, @@ -20,11 +20,11 @@ import { sampleIndexPatterns, mockDataViewsService } from './mocks'; import { documentField } from '../datasources/form_based/document_field'; jest.mock('@kbn/esql-utils', () => ({ - getESQLAdHocDataview: jest.fn(), + getESQLTimeFieldFromQuery: jest.fn(), })); -const mockGetESQLAdHocDataview = getESQLAdHocDataview as jest.MockedFunction< - typeof getESQLAdHocDataview +const mockGetESQLTimeFieldFromQuery = getESQLTimeFieldFromQuery as jest.MockedFunction< + typeof getESQLTimeFieldFromQuery >; describe('loader', () => { @@ -364,7 +364,8 @@ describe('loader', () => { const mockDataViews = mockDataViewsService() as unknown as DataViewsContract; beforeEach(() => { - mockGetESQLAdHocDataview.mockReset(); + mockGetESQLTimeFieldFromQuery.mockReset(); + (mockDataViews.clearInstanceCache as jest.Mock).mockClear(); }); it('should return adHocDataViews unchanged when textBasedState is undefined', async () => { @@ -380,7 +381,7 @@ describe('loader', () => { }); expect(result).toBe(adHocDataViews); - expect(mockGetESQLAdHocDataview).not.toHaveBeenCalled(); + expect(mockGetESQLTimeFieldFromQuery).not.toHaveBeenCalled(); }); it('should return adHocDataViews unchanged when layers is empty', async () => { @@ -396,7 +397,7 @@ describe('loader', () => { }); expect(result).toEqual(adHocDataViews); - expect(mockGetESQLAdHocDataview).not.toHaveBeenCalled(); + expect(mockGetESQLTimeFieldFromQuery).not.toHaveBeenCalled(); }); it('should skip layers without an ES|QL query', async () => { @@ -415,7 +416,7 @@ describe('loader', () => { }); expect(result).toEqual({}); - expect(mockGetESQLAdHocDataview).not.toHaveBeenCalled(); + expect(mockGetESQLTimeFieldFromQuery).not.toHaveBeenCalled(); }); it('should skip enrichment when the existing spec already has a timeFieldName', async () => { @@ -436,10 +437,11 @@ describe('loader', () => { }); expect(result).toEqual(adHocDataViews); - expect(mockGetESQLAdHocDataview).not.toHaveBeenCalled(); + expect(mockGetESQLTimeFieldFromQuery).not.toHaveBeenCalled(); + expect(mockDataViews.clearInstanceCache).not.toHaveBeenCalled(); }); - it('should call getESQLAdHocDataview when spec is missing timeFieldName', async () => { + it('should patch existing spec with timeFieldName and evict stale cache', async () => { const adHocDataViews: Record = { dv1: { id: 'dv1', title: 'logs-*' }, }; @@ -449,10 +451,7 @@ describe('loader', () => { }, } as unknown as TextBasedPersistedState; - mockGetESQLAdHocDataview.mockResolvedValue({ - id: 'dv1', - toSpec: () => ({ id: 'dv1', title: 'logs-*', timeFieldName: '@timestamp' }), - } as never); + mockGetESQLTimeFieldFromQuery.mockResolvedValue('@timestamp'); const result = await ensureESQLTimeFieldOnAdHocDataViews({ adHocDataViews, @@ -461,31 +460,23 @@ describe('loader', () => { http: mockHttp, }); - expect(mockGetESQLAdHocDataview).toHaveBeenCalledWith( - expect.objectContaining({ - query: 'FROM logs-*', - options: { - skipFetchFields: true, - createNewInstanceEvenIfCachedOneAvailable: true, - }, - http: mockHttp, - }) - ); + expect(mockGetESQLTimeFieldFromQuery).toHaveBeenCalledWith({ + query: 'FROM logs-*', + http: mockHttp, + }); expect(result.dv1).toEqual({ id: 'dv1', title: 'logs-*', timeFieldName: '@timestamp' }); + expect(mockDataViews.clearInstanceCache).toHaveBeenCalledWith('dv1'); }); - it('should use freshDataView.id as the key when layer.index is falsy', async () => { + it('should not patch spec when layer.index has no matching entry', async () => { const adHocDataViews: Record = {}; const textBasedState = { layers: { - layer1: { columns: [], query: { esql: 'FROM logs-*' } }, + layer1: { columns: [], index: 'missing-id', query: { esql: 'FROM logs-*' } }, }, } as unknown as TextBasedPersistedState; - mockGetESQLAdHocDataview.mockResolvedValue({ - id: 'generated-id', - toSpec: () => ({ id: 'generated-id', title: 'logs-*', timeFieldName: '@timestamp' }), - } as never); + mockGetESQLTimeFieldFromQuery.mockResolvedValue('@timestamp'); const result = await ensureESQLTimeFieldOnAdHocDataViews({ adHocDataViews, @@ -494,11 +485,8 @@ describe('loader', () => { http: mockHttp, }); - expect(result['generated-id']).toEqual({ - id: 'generated-id', - title: 'logs-*', - timeFieldName: '@timestamp', - }); + expect(result).toEqual({}); + expect(mockDataViews.clearInstanceCache).not.toHaveBeenCalled(); }); it('should handle mixed layers: only enrich specs missing timeFieldName', async () => { @@ -513,10 +501,7 @@ describe('loader', () => { }, } as unknown as TextBasedPersistedState; - mockGetESQLAdHocDataview.mockResolvedValue({ - id: 'dv2', - toSpec: () => ({ id: 'dv2', title: 'metrics-*', timeFieldName: '@timestamp' }), - } as never); + mockGetESQLTimeFieldFromQuery.mockResolvedValue('@timestamp'); const result = await ensureESQLTimeFieldOnAdHocDataViews({ adHocDataViews, @@ -525,12 +510,15 @@ describe('loader', () => { http: mockHttp, }); - expect(mockGetESQLAdHocDataview).toHaveBeenCalledTimes(1); - expect(mockGetESQLAdHocDataview).toHaveBeenCalledWith( - expect.objectContaining({ query: 'FROM metrics-*' }) - ); + expect(mockGetESQLTimeFieldFromQuery).toHaveBeenCalledTimes(1); + expect(mockGetESQLTimeFieldFromQuery).toHaveBeenCalledWith({ + query: 'FROM metrics-*', + http: mockHttp, + }); expect(result.dv1).toEqual(adHocDataViews.dv1); expect(result.dv2.timeFieldName).toBe('@timestamp'); + expect(mockDataViews.clearInstanceCache).toHaveBeenCalledTimes(1); + expect(mockDataViews.clearInstanceCache).toHaveBeenCalledWith('dv2'); }); it('should not mutate the original adHocDataViews object', async () => { @@ -543,10 +531,7 @@ describe('loader', () => { }, } as unknown as TextBasedPersistedState; - mockGetESQLAdHocDataview.mockResolvedValue({ - id: 'dv1', - toSpec: () => ({ id: 'dv1', title: 'logs-*', timeFieldName: '@timestamp' }), - } as never); + mockGetESQLTimeFieldFromQuery.mockResolvedValue('@timestamp'); const result = await ensureESQLTimeFieldOnAdHocDataViews({ adHocDataViews, @@ -558,5 +543,28 @@ describe('loader', () => { expect(result).not.toBe(adHocDataViews); expect(adHocDataViews.dv1.timeFieldName).toBeUndefined(); }); + + it('should leave spec unchanged when time field resolves to undefined', async () => { + const adHocDataViews: Record = { + dv1: { id: 'dv1', title: 'logs-*' }, + }; + const textBasedState = { + layers: { + layer1: { columns: [], index: 'dv1', query: { esql: 'FROM logs-*' } }, + }, + } as unknown as TextBasedPersistedState; + + mockGetESQLTimeFieldFromQuery.mockResolvedValue(undefined); + + const result = await ensureESQLTimeFieldOnAdHocDataViews({ + adHocDataViews, + textBasedState, + dataViewsService: mockDataViews, + http: mockHttp, + }); + + expect(result.dv1).toEqual({ id: 'dv1', title: 'logs-*' }); + expect(mockDataViews.clearInstanceCache).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/platform/plugins/shared/lens/public/data_views_service/loader.ts b/x-pack/platform/plugins/shared/lens/public/data_views_service/loader.ts index 6600e14de5eef..06e45f089140e 100644 --- a/x-pack/platform/plugins/shared/lens/public/data_views_service/loader.ts +++ b/x-pack/platform/plugins/shared/lens/public/data_views_service/loader.ts @@ -14,7 +14,7 @@ import type { } from '@kbn/data-views-plugin/public'; import { keyBy } from 'lodash'; import type { HttpStart } from '@kbn/core/public'; -import { getESQLAdHocDataview } from '@kbn/esql-utils'; +import { getESQLTimeFieldFromQuery } from '@kbn/esql-utils'; import { isOfAggregateQueryType } from '@kbn/es-query'; import type { IndexPattern, @@ -162,15 +162,15 @@ function onRestrictionMapping(agg: string): string { /** * Ensures ESQL ad-hoc DataView specs have a valid `timeFieldName` if any. * - * Persisted specs may be missing time field info. For each text-based layer with - * an ES|QL query, this function checks whether the corresponding ad-hoc DataView - * spec already has a `timeFieldName`. If it does, the spec is kept as-is. If not, - * `getESQLAdHocDataview` is called to detect the time field via the TIMEFIELD_ROUTE. + * Persisted specs may be missing time field info (e.g. when created via the Lens API). + * For each text-based layer with an ES|QL query, this function checks whether the + * corresponding ad-hoc DataView spec already has a `timeFieldName`. If not, the time + * field is resolved via the TIMEFIELD_ROUTE and patched onto the existing spec in-place. * - * After calling this function the DataViewService instance cache is also populated - * with the correct DataView, so downstream `dataViews.create(spec)` calls - * (in `loadIndexPatterns`, `getUsedDataViews`, etc.) will return the cached instance - * with the right time field — even if they receive a stale spec. + * Uses `getESQLTimeFieldFromQuery` directly instead of `getESQLAdHocDataview` to avoid + * creating a DataView instance (which would pollute the DataViewsService cache with a + * field-less entry due to `skipFetchFields`) and to avoid generating a new DataView ID + * that would mismatch the `layer.index` key used by downstream consumers. */ export async function ensureESQLTimeFieldOnAdHocDataViews({ adHocDataViews, @@ -196,24 +196,22 @@ export async function ensureESQLTimeFieldOnAdHocDataViews({ const existingSpec = layer.index ? result[layer.index] : undefined; - // Skip regeneration when the persisted spec already has a timeFieldName if (existingSpec?.timeFieldName) { continue; } - const freshDataView = await getESQLAdHocDataview({ - dataViewsService, + const timeFieldName = await getESQLTimeFieldFromQuery({ query: layer.query.esql, - options: { - skipFetchFields: true, - createNewInstanceEvenIfCachedOneAvailable: true, - }, http, }); - const spec = freshDataView.toSpec(false); - if (freshDataView.id) { - result[freshDataView.id] = spec; + if (timeFieldName && layer.index && result[layer.index]) { + result[layer.index] = { ...result[layer.index], timeFieldName }; + // Evict any stale cached DataView instance for this ID. getUsedDataViews + // runs in parallel and may have already cached an instance created from the + // original (un-patched) spec without timeFieldName. Without eviction, + // downstream dataViews.create(patchedSpec) returns the stale cached instance. + dataViewsService.clearInstanceCache(layer.index); } } diff --git a/x-pack/platform/plugins/shared/lens/public/data_views_service/mocks.ts b/x-pack/platform/plugins/shared/lens/public/data_views_service/mocks.ts index 23918ca5d4936..eb002162aad25 100644 --- a/x-pack/platform/plugins/shared/lens/public/data_views_service/mocks.ts +++ b/x-pack/platform/plugins/shared/lens/public/data_views_service/mocks.ts @@ -230,5 +230,9 @@ export function mockDataViewsService() { ]; }), create: jest.fn(), - } as unknown as Pick; + clearInstanceCache: jest.fn(), + } as unknown as Pick< + DataViewsContract, + 'get' | 'getIdsWithTitle' | 'create' | 'clearInstanceCache' + >; } diff --git a/x-pack/platform/plugins/shared/lens/test/scout/ui/parallel_tests/esql_timeseries_dashboard_api.spec.ts b/x-pack/platform/plugins/shared/lens/test/scout/ui/parallel_tests/esql_timeseries_dashboard_api.spec.ts new file mode 100644 index 0000000000000..d4f9a45f0efaa --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/test/scout/ui/parallel_tests/esql_timeseries_dashboard_api.spec.ts @@ -0,0 +1,220 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DebugState } from '@elastic/charts'; +import { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-common'; +import { spaceTest } from '@kbn/scout'; +import { expect } from '@kbn/scout/ui'; + +import { testData } from '../fixtures'; + +const DASHBOARD_API_PATH = 'api/dashboards'; +const DASHBOARD_API_HEADERS = { + 'Content-Type': 'application/json', + 'elastic-api-version': '2023-10-31', +} as const; + +const LOGSTASH_ABSOLUTE_RANGE = { + from: '2015-09-19T06:31:44.000Z', + to: '2015-09-23T18:31:44.000Z', +} as const; + +type EsqlTimeseriesCase = Readonly<{ + description: string; + query: string; + xColumn: string; + yColumn: string; +}>; + +const TIMESERIES_CASES: EsqlTimeseriesCase[] = [ + { + description: 'TBUCKET', + query: 'FROM logstash-* | STATS count = COUNT(*) BY ts = TBUCKET(50)', + xColumn: 'ts', + yColumn: 'count', + }, + { + description: 'BUCKET on timestamp', + query: 'FROM logstash-* | STATS count = COUNT(*) BY ts = BUCKET(@timestamp, 1 hour)', + xColumn: 'ts', + yColumn: 'count', + }, + { + description: 'DATE_TRUNC on timestamp', + query: 'FROM logstash-* | STATS count = COUNT(*) BY ts = DATE_TRUNC(1 hour, @timestamp)', + xColumn: 'ts', + yColumn: 'count', + }, +]; + +function buildLensLineTimeseriesPanel(esql: EsqlTimeseriesCase) { + return { + type: LENS_EMBEDDABLE_TYPE, + grid: { x: 0, y: 0, w: 36, h: 20 }, + config: { + type: 'xy' as const, + title: `ES|QL ${esql.description}`, + layers: [ + { + type: 'line' as const, + ignore_global_filters: false, + sampling: 1, + data_source: { + type: 'esql' as const, + query: esql.query, + }, + x: { column: esql.xColumn }, + y: [{ column: esql.yColumn }], + }, + ], + }, + }; +} + +async function createDashboardWithLensPanel( + kbnClient: import('@kbn/scout').KbnClient, + spaceId: string, + title: string, + esql: EsqlTimeseriesCase +): Promise { + const response = await kbnClient.request<{ id: string }>({ + method: 'POST', + path: `s/${spaceId}/${DASHBOARD_API_PATH}`, + headers: DASHBOARD_API_HEADERS, + body: { + title, + time_range: { + from: LOGSTASH_ABSOLUTE_RANGE.from, + to: LOGSTASH_ABSOLUTE_RANGE.to, + mode: 'absolute' as const, + }, + panels: [buildLensLineTimeseriesPanel(esql)], + }, + }); + + expect([200, 201]).toContain(response.status); + expect(response.data.id).toBeTruthy(); + return response.data.id; +} + +async function getChartDebugState( + esqlCase: EsqlTimeseriesCase, + kbnClient: import('@kbn/scout').KbnClient, + spaceId: string, + page: import('@kbn/scout').ScoutPage, + pageObjects: import('@kbn/scout').PageObjects +): Promise { + const title = `Scout ES|QL timeseries API ${esqlCase.description} ${Date.now()}`; + const dashboardId = await createDashboardWithLensPanel(kbnClient, spaceId, title, esqlCase); + + const { dashboard } = pageObjects; + await dashboard.openDashboardWithId(dashboardId); + await dashboard.waitForPanelsToLoad(1); + + const chart = page.testSubj.locator('xyVisChart'); + await chart.locator('.echChartStatus[data-ech-render-complete="true"]').waitFor({ + state: 'attached', + timeout: 30_000, + }); + + const chartStatus = chart.locator('.echChartStatus'); + const debugJson = await chartStatus.getAttribute('data-ech-debug-state'); + return JSON.parse(debugJson ?? '{}') as DebugState; +} + +function areAllEpochMilliseconds(values: unknown[]): boolean { + return values.every((v) => typeof v === 'number' && v > 1_000_000_000_000); +} + +spaceTest.describe( + 'Lens ES|QL timeseries via dashboard API', + { tag: '@local-stateful-classic' }, + () => { + spaceTest.beforeAll(async ({ scoutSpace }) => { + await scoutSpace.uiSettings.set({ + defaultIndex: testData.DATA_VIEW_ID.LOGSTASH, + 'dateFormat:tz': 'UTC', + 'timepicker:timeDefaults': `{ "from": "${testData.LOGSTASH_IN_RANGE_DATES.from}", "to": "${testData.LOGSTASH_IN_RANGE_DATES.to}"}`, + }); + }); + + spaceTest.beforeEach(async ({ browserAuth, context }) => { + await browserAuth.loginAsPrivilegedUser(); + await context.addInitScript(() => { + (window as unknown as { _echDebugStateFlag?: boolean })._echDebugStateFlag = true; + }); + }); + + spaceTest.afterAll(async ({ scoutSpace }) => { + await scoutSpace.uiSettings.unset('defaultIndex', 'dateFormat:tz', 'timepicker:timeDefaults'); + await scoutSpace.savedObjects.cleanStandardList(); + }); + + spaceTest( + 'renders a time-scaled x-axis for TBUCKET', + async ({ kbnClient, scoutSpace, page, pageObjects }) => { + const debug = await getChartDebugState( + TIMESERIES_CASES[0], + kbnClient, + scoutSpace.id, + page, + pageObjects + ); + const xAxis = debug.axes?.x?.[0]; + expect(xAxis, 'Expected chart debug state to include an x-axis').toBeDefined(); + const tickValues = xAxis!.values ?? []; + expect(tickValues.length, 'Expected at least one x-axis tick value').toBeGreaterThan(0); + expect( + areAllEpochMilliseconds(tickValues), + `Expected epoch-ms x-axis ticks; got ${JSON.stringify(tickValues.slice(0, 5))}` + ).toBe(true); + } + ); + + spaceTest( + 'renders a time-scaled x-axis for BUCKET on timestamp', + async ({ kbnClient, scoutSpace, page, pageObjects }) => { + const debug = await getChartDebugState( + TIMESERIES_CASES[1], + kbnClient, + scoutSpace.id, + page, + pageObjects + ); + const xAxis = debug.axes?.x?.[0]; + expect(xAxis, 'Expected chart debug state to include an x-axis').toBeDefined(); + const tickValues = xAxis!.values ?? []; + expect(tickValues.length, 'Expected at least one x-axis tick value').toBeGreaterThan(0); + expect( + areAllEpochMilliseconds(tickValues), + `Expected epoch-ms x-axis ticks; got ${JSON.stringify(tickValues.slice(0, 5))}` + ).toBe(true); + } + ); + + spaceTest( + 'renders a time-scaled x-axis for DATE_TRUNC on timestamp', + async ({ kbnClient, scoutSpace, page, pageObjects }) => { + const debug = await getChartDebugState( + TIMESERIES_CASES[2], + kbnClient, + scoutSpace.id, + page, + pageObjects + ); + const xAxis = debug.axes?.x?.[0]; + expect(xAxis, 'Expected chart debug state to include an x-axis').toBeDefined(); + const tickValues = xAxis!.values ?? []; + expect(tickValues.length, 'Expected at least one x-axis tick value').toBeGreaterThan(0); + expect( + areAllEpochMilliseconds(tickValues), + `Expected epoch-ms x-axis ticks; got ${JSON.stringify(tickValues.slice(0, 5))}` + ).toBe(true); + } + ); + } +);