Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<TimeSeriesResult> {
const timeSeriesQueryParams = {
index: model.index,
Expand All @@ -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<TimeSeriesResult>(`${INDEX_THRESHOLD_DATA_API_ROOT}/_time_series_query`, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -130,7 +140,8 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
groupBy,
threshold,
} = ruleParams;
const { http, uiSettings } = useKibana().services;
const { http, uiSettings, cps } = useKibana<KibanaThresholdVizServices>().services;
const projectRouting = cps?.cpsManager?.getProjectRouting();
const [loadingState, setLoadingState] = useState<LoadingStateType | null>(null);
const [hasError, setHasError] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<undefined | string>(undefined);
Expand All @@ -152,7 +163,7 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
try {
setLoadingState(loadingState ? LoadingStateType.Refresh : LoadingStateType.FirstLoad);
setVisualizationData(
await getVisualizationData(alertWithoutActions, visualizeOptions, http!)
await getVisualizationData(alertWithoutActions, visualizeOptions, http!, projectRouting)
);
setHasError(false);
setErrorMessage(undefined);
Expand All @@ -177,6 +188,7 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
groupBy,
threshold,
startVisualizationAt,
projectRouting,
]);

if (!charts || !uiSettings || !dataFieldsFormats) {
Expand Down Expand Up @@ -340,12 +352,14 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
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<string, Array<[number, number]>> = {};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export interface TimeSeriesQueryParameters {
export async function fetchDataViewBase(
esClient: ElasticsearchClient,
index: string | string[],
fieldNames: string[]
fieldNames: string[],
projectRouting?: string
): Promise<DataViewBase> {
const indices = Array.isArray(index) ? index : [index];
const title = indices.join(',');
Expand All @@ -50,6 +51,7 @@ export async function fetchDataViewBase(
fields: fieldNames,
ignore_unavailable: true,
allow_no_indices: true,
...(projectRouting ? { project_routing: projectRouting } : {}),
});

const fields: DataViewFieldBase[] = [];
Expand Down Expand Up @@ -88,6 +90,7 @@ export async function timeSeriesQuery(
dateStart,
dateEnd,
filterKuery,
project_routing: projectRouting,
} = queryParams;

const window = `${timeWindowSize}${timeWindowUnit}`;
Expand All @@ -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}`
Expand Down Expand Up @@ -150,6 +153,7 @@ export async function timeSeriesQuery(
}),
ignore_unavailable: true,
allow_no_indices: true,
...(projectRouting ? { project_routing: projectRouting } : {}),
};

// add the aggregations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading