Skip to content

Commit b84dbac

Browse files
authored
Fix cross-project search for index threshold chart preview (elastic#261593)
### Summary Index threshold rule UI could list indices using the CPS project scope (via `POST .../data/_indices` and `project_routing`), but the chart preview called `POST .../internal/triggers_actions_ui/data/_time_series_query` without `project_routing`. The server-side Elasticsearch client then defaulted to origin-only routing, so preview did not match the picker. This change threads optional `**project_routing**` through the time-series query API and the threshold visualization so preview uses the same CPS scope as index selection. ### Changes - **`triggers_actions_ui`**: Extend `TimeSeriesQuerySchema` with optional `project_routing`; pass it from `timeSeriesQuery` into **`search`** and **`fieldCaps`** (including `fetchDataViewBase` for KQL filter typing). - **`stack_alerts`**: `getThresholdRuleVisualizationData` accepts optional `projectRouting` and sends **`project_routing`** in the JSON body; **`ThresholdVisualization`** reads `cps.cpsManager.getProjectRouting()` and passes it through, with a refetch when routing changes. - **Tests**: Schema validation for `project_routing`; unit tests for API body shape; visualization tests for CPS vs no CPS; `time_series_query` tests assert ES calls include `project_routing` when set. ### How to test 1. On a CPS-enabled serverless deployment, set the project picker to search linked projects (`_alias:*` or equivalent). 2. Create or edit an index threshold rule targeting data outside the origin project. 3. Confirm the preview chart loads data consistent with the selected indices (not empty or scoped only to the origin project). Made with [Cursor](https://cursor.com)
1 parent 7936b66 commit b84dbac

8 files changed

Lines changed: 209 additions & 6 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { HttpSetup } from '@kbn/core/public';
9+
import { getThresholdRuleVisualizationData } from './index_threshold_api';
10+
11+
describe('getThresholdRuleVisualizationData', () => {
12+
const model = {
13+
index: ['logs-*'],
14+
timeField: '@timestamp',
15+
aggType: 'count',
16+
groupBy: 'all',
17+
termField: undefined,
18+
termSize: undefined,
19+
timeWindowSize: 5,
20+
timeWindowUnit: 'm',
21+
threshold: [1],
22+
thresholdComparator: '>',
23+
};
24+
25+
const visualizeOptions = {
26+
rangeFrom: '2024-01-01T00:00:00.000Z',
27+
rangeTo: '2024-01-02T00:00:00.000Z',
28+
interval: '1m',
29+
};
30+
31+
let httpPost: jest.Mock;
32+
33+
beforeEach(() => {
34+
httpPost = jest.fn().mockResolvedValue({ results: [] });
35+
});
36+
37+
it('includes project_routing in the request body when projectRouting is set', async () => {
38+
const http = { post: httpPost } as unknown as HttpSetup;
39+
40+
await getThresholdRuleVisualizationData({
41+
model,
42+
visualizeOptions,
43+
http,
44+
projectRouting: '_alias:*',
45+
});
46+
47+
expect(httpPost).toHaveBeenCalledTimes(1);
48+
const body = JSON.parse(httpPost.mock.calls[0][1].body as string);
49+
expect(body.project_routing).toBe('_alias:*');
50+
expect(body.index).toEqual(model.index);
51+
expect(body.timeField).toBe(model.timeField);
52+
});
53+
54+
it('omits project_routing from the request body when projectRouting is undefined', async () => {
55+
const http = { post: httpPost } as unknown as HttpSetup;
56+
57+
await getThresholdRuleVisualizationData({
58+
model,
59+
visualizeOptions,
60+
http,
61+
});
62+
63+
const body = JSON.parse(httpPost.mock.calls[0][1].body as string);
64+
expect(body).not.toHaveProperty('project_routing');
65+
});
66+
});

x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/index_threshold_api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@ export interface GetThresholdRuleVisualizationDataParams {
1919
interval: string;
2020
};
2121
http: HttpSetup;
22+
/** Cross-project search scope (serverless); forwarded as `project_routing` on the request body. */
23+
projectRouting?: string;
2224
}
2325

2426
export async function getThresholdRuleVisualizationData({
2527
model,
2628
visualizeOptions,
2729
http,
30+
projectRouting,
2831
}: GetThresholdRuleVisualizationDataParams): Promise<TimeSeriesResult> {
2932
const timeSeriesQueryParams = {
3033
index: model.index,
@@ -40,6 +43,7 @@ export async function getThresholdRuleVisualizationData({
4043
dateStart: new Date(visualizeOptions.rangeFrom).toISOString(),
4144
dateEnd: new Date(visualizeOptions.rangeTo).toISOString(),
4245
interval: visualizeOptions.interval,
46+
...(projectRouting ? { project_routing: projectRouting } : {}),
4347
};
4448

4549
return await http.post<TimeSeriesResult>(`${INDEX_THRESHOLD_DATA_API_ROOT}/_time_series_query`, {

x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/visualization.test.tsx

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,21 @@ dataMock.fieldFormats = {
4343
} as unknown as DataPublicPluginStart['fieldFormats'];
4444

4545
describe('ThresholdVisualization', () => {
46-
beforeAll(() => {
46+
beforeEach(() => {
4747
(useKibana as jest.Mock).mockReturnValue({
4848
services: {
4949
uiSettings: uiSettingsServiceMock.createSetupContract(),
50+
http: { post: jest.fn() },
5051
},
5152
});
53+
getThresholdRuleVisualizationData.mockImplementation(() =>
54+
Promise.resolve({
55+
results: [
56+
{ group: 'a', metrics: [['b', 2]] },
57+
{ group: 'a', metrics: [['b', 10]] },
58+
],
59+
})
60+
);
5261
});
5362

5463
const ruleParams = {
@@ -208,4 +217,40 @@ describe('ThresholdVisualization', () => {
208217
`No data matches this queryCheck that your time range and filters are correct.`
209218
);
210219
});
220+
221+
test('passes projectRouting from CPS manager to getThresholdRuleVisualizationData', async () => {
222+
(useKibana as jest.Mock).mockReturnValue({
223+
services: {
224+
uiSettings: uiSettingsServiceMock.createSetupContract(),
225+
http: { post: jest.fn() },
226+
cps: {
227+
cpsManager: {
228+
getProjectRouting: jest.fn(() => '_alias:*'),
229+
},
230+
},
231+
},
232+
});
233+
234+
await setup();
235+
236+
expect(getThresholdRuleVisualizationData).toHaveBeenCalledWith(
237+
expect.objectContaining({
238+
projectRouting: '_alias:*',
239+
})
240+
);
241+
});
242+
243+
test('passes undefined projectRouting when CPS manager is absent', async () => {
244+
(useKibana as jest.Mock).mockReturnValue({
245+
services: {
246+
uiSettings: uiSettingsServiceMock.createSetupContract(),
247+
http: { post: jest.fn() },
248+
},
249+
});
250+
251+
await setup();
252+
253+
const firstCallArg = getThresholdRuleVisualizationData.mock.calls[0][0];
254+
expect(firstCallArg.projectRouting).toBeUndefined();
255+
});
211256
});

x-pack/platform/plugins/shared/stack_alerts/public/rule_types/threshold/visualization.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ import type { GetThresholdRuleVisualizationDataParams } from './index_threshold_
4141
import { getThresholdRuleVisualizationData } from './index_threshold_api';
4242
import type { IndexThresholdRuleParams } from './types';
4343

44+
interface KibanaThresholdVizServices {
45+
http: HttpSetup;
46+
uiSettings: IUiSettingsClient;
47+
cps?: {
48+
cpsManager?: {
49+
getProjectRouting: () => string | undefined;
50+
};
51+
};
52+
}
53+
4454
const chartThemeOverrides = (): PartialTheme => {
4555
return {
4656
lineSeriesStyle: {
@@ -130,7 +140,8 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
130140
groupBy,
131141
threshold,
132142
} = ruleParams;
133-
const { http, uiSettings } = useKibana().services;
143+
const { http, uiSettings, cps } = useKibana<KibanaThresholdVizServices>().services;
144+
const projectRouting = cps?.cpsManager?.getProjectRouting();
134145
const [loadingState, setLoadingState] = useState<LoadingStateType | null>(null);
135146
const [hasError, setHasError] = useState<boolean>(false);
136147
const [errorMessage, setErrorMessage] = useState<undefined | string>(undefined);
@@ -152,7 +163,7 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
152163
try {
153164
setLoadingState(loadingState ? LoadingStateType.Refresh : LoadingStateType.FirstLoad);
154165
setVisualizationData(
155-
await getVisualizationData(alertWithoutActions, visualizeOptions, http!)
166+
await getVisualizationData(alertWithoutActions, visualizeOptions, http!, projectRouting)
156167
);
157168
setHasError(false);
158169
setErrorMessage(undefined);
@@ -177,6 +188,7 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
177188
groupBy,
178189
threshold,
179190
startVisualizationAt,
191+
projectRouting,
180192
]);
181193

182194
if (!charts || !uiSettings || !dataFieldsFormats) {
@@ -340,12 +352,14 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
340352
async function getVisualizationData(
341353
model: IndexThresholdRuleParams,
342354
visualizeOptions: GetThresholdRuleVisualizationDataParams['visualizeOptions'],
343-
http: HttpSetup
355+
http: HttpSetup,
356+
projectRouting?: string
344357
) {
345358
const vizData = await getThresholdRuleVisualizationData({
346359
model,
347360
visualizeOptions,
348361
http,
362+
projectRouting,
349363
});
350364
const result: Record<string, Array<[number, number]>> = {};
351365

x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_query.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,40 @@ describe('timeSeriesQuery', () => {
9494
});
9595
});
9696

97+
it('forwards project_routing to search and fieldCaps when set', async () => {
98+
esClient.fieldCaps.mockResolvedValueOnce({
99+
indices: ['index-name'] as estypes.Indices,
100+
fields: {
101+
'event.provider': {
102+
keyword: {
103+
type: 'keyword',
104+
metadata_field: false,
105+
searchable: true,
106+
aggregatable: true,
107+
},
108+
},
109+
},
110+
} as estypes.FieldCapsResponse);
111+
await timeSeriesQuery({
112+
...params,
113+
query: {
114+
...params.query,
115+
filterKuery: 'event.provider: alerting',
116+
project_routing: '_alias:*',
117+
},
118+
});
119+
expect(esClient.fieldCaps).toHaveBeenCalledWith(
120+
expect.objectContaining({
121+
fields: ['event.provider'],
122+
project_routing: '_alias:*',
123+
})
124+
);
125+
expect(esClient.search).toHaveBeenCalledWith(
126+
expect.objectContaining({ project_routing: '_alias:*' }),
127+
expect.any(Object)
128+
);
129+
});
130+
97131
it('generates a wildcard query for keyword fields with wildcard patterns', async () => {
98132
esClient.fieldCaps.mockResolvedValueOnce({
99133
indices: ['index-name'] as estypes.Indices,
@@ -868,6 +902,23 @@ describe('fetchDataViewBase', () => {
868902
expect(result.title).toBe('index-a,index-b');
869903
expect(result.fields).toEqual([]);
870904
});
905+
906+
it('passes project_routing to fieldCaps when provided', async () => {
907+
esClient.fieldCaps.mockResolvedValueOnce({
908+
indices: ['my-index'] as estypes.Indices,
909+
fields: {},
910+
} as estypes.FieldCapsResponse);
911+
912+
await fetchDataViewBase(esClient, 'my-index', ['host.name'], '_alias:*');
913+
914+
expect(esClient.fieldCaps).toHaveBeenCalledWith(
915+
expect.objectContaining({
916+
index: ['my-index'],
917+
fields: ['host.name'],
918+
project_routing: '_alias:*',
919+
})
920+
);
921+
});
871922
});
872923

873924
describe('getResultFromEs', () => {

x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_query.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ export interface TimeSeriesQueryParameters {
4040
export async function fetchDataViewBase(
4141
esClient: ElasticsearchClient,
4242
index: string | string[],
43-
fieldNames: string[]
43+
fieldNames: string[],
44+
projectRouting?: string
4445
): Promise<DataViewBase> {
4546
const indices = Array.isArray(index) ? index : [index];
4647
const title = indices.join(',');
@@ -50,6 +51,7 @@ export async function fetchDataViewBase(
5051
fields: fieldNames,
5152
ignore_unavailable: true,
5253
allow_no_indices: true,
54+
...(projectRouting ? { project_routing: projectRouting } : {}),
5355
});
5456

5557
const fields: DataViewFieldBase[] = [];
@@ -88,6 +90,7 @@ export async function timeSeriesQuery(
8890
dateStart,
8991
dateEnd,
9092
filterKuery,
93+
project_routing: projectRouting,
9194
} = queryParams;
9295

9396
const window = `${timeWindowSize}${timeWindowUnit}`;
@@ -101,7 +104,7 @@ export async function timeSeriesQuery(
101104
const fieldNames = getKqlFieldNames(kueryNode);
102105
if (fieldNames.length > 0) {
103106
try {
104-
dataView = await fetchDataViewBase(esClient, index, fieldNames);
107+
dataView = await fetchDataViewBase(esClient, index, fieldNames, projectRouting);
105108
} catch (err) {
106109
logger.warn(
107110
`indexThreshold timeSeriesQuery: failed to fetch field caps for filter, falling back to untyped conversion: ${err.message}`
@@ -150,6 +153,7 @@ export async function timeSeriesQuery(
150153
}),
151154
ignore_unavailable: true,
152155
allow_no_indices: true,
156+
...(projectRouting ? { project_routing: projectRouting } : {}),
153157
};
154158

155159
// add the aggregations

x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_types.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,23 @@ describe('TimeSeriesParams validate()', () => {
103103
);
104104
});
105105

106+
it('accepts optional project_routing', async () => {
107+
params.project_routing = '_alias:*';
108+
expect(validate()).toEqual(expect.objectContaining({ project_routing: '_alias:*' }));
109+
});
110+
111+
it('omits project_routing when unset', async () => {
112+
const result = validate();
113+
expect(result).not.toHaveProperty('project_routing');
114+
});
115+
116+
it('fails for invalid project_routing type', async () => {
117+
params.project_routing = 99;
118+
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
119+
`"[project_routing]: expected value of type [string] but got [number]"`
120+
);
121+
});
122+
106123
function onValidate(): () => void {
107124
return () => validate();
108125
}

x-pack/platform/plugins/shared/triggers_actions_ui/server/data/lib/time_series_types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export const TimeSeriesQuerySchema = schema.object(
4343
// this value indicates the amount of time between time series dates
4444
// that will be calculated.
4545
interval: schema.maybe(schema.string({ validate: validateDuration })),
46+
// Cross-project search (serverless): aligns ES scope with the CPS picker / _indices route.
47+
project_routing: schema.maybe(schema.string()),
4648
},
4749
{
4850
validate: validateBody,

0 commit comments

Comments
 (0)