diff --git a/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/index_threshold_api.test.ts b/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/index_threshold_api.test.ts new file mode 100644 index 0000000000000..1b262473bd99b --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/index_threshold_api.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { HttpSetup } from '@kbn/core/public'; +import { getThresholdRuleVisualizationData } from './index_threshold_api'; + +describe('getThresholdRuleVisualizationData', () => { + const model = { + index: ['logs-*'], + timeField: '@timestamp', + aggType: 'count', + groupBy: 'all', + termField: undefined, + termSize: undefined, + timeWindowSize: 5, + timeWindowUnit: 'm', + threshold: [1], + thresholdComparator: '>', + }; + + const visualizeOptions = { + rangeFrom: '2024-01-01T00:00:00.000Z', + rangeTo: '2024-01-02T00:00:00.000Z', + interval: '1m', + }; + + let httpPost: jest.Mock; + + beforeEach(() => { + httpPost = jest.fn().mockResolvedValue({ results: [] }); + }); + + it('includes project_routing in the request body when projectRouting is set', async () => { + const http = { post: httpPost } as unknown as HttpSetup; + + await getThresholdRuleVisualizationData({ + model, + visualizeOptions, + http, + projectRouting: '_alias:*', + }); + + expect(httpPost).toHaveBeenCalledTimes(1); + const body = JSON.parse(httpPost.mock.calls[0][1].body as string); + expect(body.project_routing).toBe('_alias:*'); + expect(body.index).toEqual(model.index); + expect(body.timeField).toBe(model.timeField); + }); + + it('omits project_routing from the request body when projectRouting is undefined', async () => { + const http = { post: httpPost } as unknown as HttpSetup; + + await getThresholdRuleVisualizationData({ + model, + visualizeOptions, + http, + }); + + const body = JSON.parse(httpPost.mock.calls[0][1].body as string); + expect(body).not.toHaveProperty('project_routing'); + }); +}); diff --git a/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/index_threshold_api.ts b/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/index_threshold_api.ts index a2692239c72c8..52c1dd9d18e56 100644 --- a/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/index_threshold_api.ts +++ b/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/index_threshold_api.ts @@ -19,12 +19,15 @@ export interface GetThresholdRuleVisualizationDataParams { interval: string; }; http: HttpSetup; + /** Cross-project search scope (serverless); forwarded as `project_routing` on the request body. */ + projectRouting?: string; } export async function getThresholdRuleVisualizationData({ model, visualizeOptions, http, + projectRouting, }: GetThresholdRuleVisualizationDataParams): Promise { const timeSeriesQueryParams = { index: model.index, @@ -40,6 +43,7 @@ export async function getThresholdRuleVisualizationData({ dateStart: new Date(visualizeOptions.rangeFrom).toISOString(), dateEnd: new Date(visualizeOptions.rangeTo).toISOString(), interval: visualizeOptions.interval, + ...(projectRouting ? { project_routing: projectRouting } : {}), }; return await http.post(`${INDEX_THRESHOLD_DATA_API_ROOT}/_time_series_query`, { diff --git a/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/visualization.test.tsx b/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/visualization.test.tsx index 1f9a40042fbed..0544498b3ef6c 100644 --- a/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/visualization.test.tsx +++ b/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/visualization.test.tsx @@ -43,12 +43,21 @@ dataMock.fieldFormats = { } as unknown as DataPublicPluginStart['fieldFormats']; describe('ThresholdVisualization', () => { - beforeAll(() => { + beforeEach(() => { (useKibana as jest.Mock).mockReturnValue({ services: { uiSettings: uiSettingsServiceMock.createSetupContract(), + http: { post: jest.fn() }, }, }); + getThresholdRuleVisualizationData.mockImplementation(() => + Promise.resolve({ + results: [ + { group: 'a', metrics: [['b', 2]] }, + { group: 'a', metrics: [['b', 10]] }, + ], + }) + ); }); const ruleParams = { @@ -208,4 +217,40 @@ describe('ThresholdVisualization', () => { `No data matches this queryCheck that your time range and filters are correct.` ); }); + + test('passes projectRouting from CPS manager to getThresholdRuleVisualizationData', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + uiSettings: uiSettingsServiceMock.createSetupContract(), + http: { post: jest.fn() }, + cps: { + cpsManager: { + getProjectRouting: jest.fn(() => '_alias:*'), + }, + }, + }, + }); + + await setup(); + + expect(getThresholdRuleVisualizationData).toHaveBeenCalledWith( + expect.objectContaining({ + projectRouting: '_alias:*', + }) + ); + }); + + test('passes undefined projectRouting when CPS manager is absent', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + uiSettings: uiSettingsServiceMock.createSetupContract(), + http: { post: jest.fn() }, + }, + }); + + await setup(); + + const firstCallArg = getThresholdRuleVisualizationData.mock.calls[0][0]; + expect(firstCallArg.projectRouting).toBeUndefined(); + }); }); diff --git a/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/visualization.tsx b/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/visualization.tsx index d8d4bbcb87199..8304d7b7e8e26 100644 --- a/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/visualization.tsx +++ b/x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/visualization.tsx @@ -41,6 +41,16 @@ import type { GetThresholdRuleVisualizationDataParams } from './index_threshold_ import { getThresholdRuleVisualizationData } from './index_threshold_api'; import type { IndexThresholdRuleParams } from './types'; +interface KibanaThresholdVizServices { + http: HttpSetup; + uiSettings: IUiSettingsClient; + cps?: { + cpsManager?: { + getProjectRouting: () => string | undefined; + }; + }; +} + const chartThemeOverrides = (): PartialTheme => { return { lineSeriesStyle: { @@ -130,7 +140,8 @@ export const ThresholdVisualization: React.FunctionComponent = ({ groupBy, threshold, } = ruleParams; - const { http, uiSettings } = useKibana().services; + const { http, uiSettings, cps } = useKibana().services; + const projectRouting = cps?.cpsManager?.getProjectRouting(); const [loadingState, setLoadingState] = useState(null); const [hasError, setHasError] = useState(false); const [errorMessage, setErrorMessage] = useState(undefined); @@ -152,7 +163,7 @@ export const ThresholdVisualization: React.FunctionComponent = ({ try { setLoadingState(loadingState ? LoadingStateType.Refresh : LoadingStateType.FirstLoad); setVisualizationData( - await getVisualizationData(alertWithoutActions, visualizeOptions, http!) + await getVisualizationData(alertWithoutActions, visualizeOptions, http!, projectRouting) ); setHasError(false); setErrorMessage(undefined); @@ -177,6 +188,7 @@ export const ThresholdVisualization: React.FunctionComponent = ({ groupBy, threshold, startVisualizationAt, + projectRouting, ]); if (!charts || !uiSettings || !dataFieldsFormats) { @@ -340,12 +352,14 @@ export const ThresholdVisualization: React.FunctionComponent = ({ async function getVisualizationData( model: IndexThresholdRuleParams, visualizeOptions: GetThresholdRuleVisualizationDataParams['visualizeOptions'], - http: HttpSetup + http: HttpSetup, + projectRouting?: string ) { const vizData = await getThresholdRuleVisualizationData({ model, visualizeOptions, http, + projectRouting, }); const result: Record> = {}; diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_query.test.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_query.test.ts index 3db514ecd9812..b8ccd45714547 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_query.test.ts +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_query.test.ts @@ -94,6 +94,40 @@ describe('timeSeriesQuery', () => { }); }); + it('forwards project_routing to search and fieldCaps when set', async () => { + esClient.fieldCaps.mockResolvedValueOnce({ + indices: ['index-name'] as estypes.Indices, + fields: { + 'event.provider': { + keyword: { + type: 'keyword', + metadata_field: false, + searchable: true, + aggregatable: true, + }, + }, + }, + } as estypes.FieldCapsResponse); + await timeSeriesQuery({ + ...params, + query: { + ...params.query, + filterKuery: 'event.provider: alerting', + project_routing: '_alias:*', + }, + }); + expect(esClient.fieldCaps).toHaveBeenCalledWith( + expect.objectContaining({ + fields: ['event.provider'], + project_routing: '_alias:*', + }) + ); + expect(esClient.search).toHaveBeenCalledWith( + expect.objectContaining({ project_routing: '_alias:*' }), + expect.any(Object) + ); + }); + it('generates a wildcard query for keyword fields with wildcard patterns', async () => { esClient.fieldCaps.mockResolvedValueOnce({ indices: ['index-name'] as estypes.Indices, @@ -868,6 +902,23 @@ describe('fetchDataViewBase', () => { expect(result.title).toBe('index-a,index-b'); expect(result.fields).toEqual([]); }); + + it('passes project_routing to fieldCaps when provided', async () => { + esClient.fieldCaps.mockResolvedValueOnce({ + indices: ['my-index'] as estypes.Indices, + fields: {}, + } as estypes.FieldCapsResponse); + + await fetchDataViewBase(esClient, 'my-index', ['host.name'], '_alias:*'); + + expect(esClient.fieldCaps).toHaveBeenCalledWith( + expect.objectContaining({ + index: ['my-index'], + fields: ['host.name'], + project_routing: '_alias:*', + }) + ); + }); }); describe('getResultFromEs', () => { diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_query.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_query.ts index 92efc95dd7ead..cd0e0dd053884 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_query.ts +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_query.ts @@ -40,7 +40,8 @@ export interface TimeSeriesQueryParameters { export async function fetchDataViewBase( esClient: ElasticsearchClient, index: string | string[], - fieldNames: string[] + fieldNames: string[], + projectRouting?: string ): Promise { const indices = Array.isArray(index) ? index : [index]; const title = indices.join(','); @@ -50,6 +51,7 @@ export async function fetchDataViewBase( fields: fieldNames, ignore_unavailable: true, allow_no_indices: true, + ...(projectRouting ? { project_routing: projectRouting } : {}), }); const fields: DataViewFieldBase[] = []; @@ -88,6 +90,7 @@ export async function timeSeriesQuery( dateStart, dateEnd, filterKuery, + project_routing: projectRouting, } = queryParams; const window = `${timeWindowSize}${timeWindowUnit}`; @@ -101,7 +104,7 @@ export async function timeSeriesQuery( const fieldNames = getKqlFieldNames(kueryNode); if (fieldNames.length > 0) { try { - dataView = await fetchDataViewBase(esClient, index, fieldNames); + dataView = await fetchDataViewBase(esClient, index, fieldNames, projectRouting); } catch (err) { logger.warn( `indexThreshold timeSeriesQuery: failed to fetch field caps for filter, falling back to untyped conversion: ${err.message}` @@ -150,6 +153,7 @@ export async function timeSeriesQuery( }), ignore_unavailable: true, allow_no_indices: true, + ...(projectRouting ? { project_routing: projectRouting } : {}), }; // add the aggregations diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_types.test.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_types.test.ts index dc04877f42350..2062f65ef1679 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_types.test.ts +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_types.test.ts @@ -103,6 +103,23 @@ describe('TimeSeriesParams validate()', () => { ); }); + it('accepts optional project_routing', async () => { + params.project_routing = '_alias:*'; + expect(validate()).toEqual(expect.objectContaining({ project_routing: '_alias:*' })); + }); + + it('omits project_routing when unset', async () => { + const result = validate(); + expect(result).not.toHaveProperty('project_routing'); + }); + + it('fails for invalid project_routing type', async () => { + params.project_routing = 99; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[project_routing]: expected value of type [string] but got [number]"` + ); + }); + function onValidate(): () => void { return () => validate(); } diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_types.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_types.ts index d9a1476715aba..df8fe4a2965ff 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_types.ts +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_types.ts @@ -43,6 +43,8 @@ export const TimeSeriesQuerySchema = schema.object( // this value indicates the amount of time between time series dates // that will be calculated. interval: schema.maybe(schema.string({ validate: validateDuration })), + // Cross-project search (serverless): aligns ES scope with the CPS picker / _indices route. + project_routing: schema.maybe(schema.string()), }, { validate: validateBody,