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
2 changes: 2 additions & 0 deletions x-pack/platform/plugins/shared/maintenance_windows/moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ dependsOn:
- '@kbn/shared-ux-router'
- '@kbn/response-ops-rules-apis'
- '@kbn/core-test-helpers-model-versions'
- '@kbn/rule-data-utils'
- '@kbn/alerts-as-data-utils'
tags:
- plugin
- prod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,61 @@ describe('MaintenanceWindowClient - create', () => {
`);
});

it.each([
['test*', 'test*'],
['test rule*', 'test rule*'],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need to figure out, in the KQL prompter, what they return when you don't use the KQL controls, but click the "Use Query DSL" and enter in your own DSL. And then test that here as well.

It's not clear to me why using Query DSL in the KQL picker would need an index pattern, so I was wondering if there was something different about using the Use Query DSL option, that we weren't handling correctrly.

Copy link
Copy Markdown
Contributor Author

@georgianaonoleata1904 georgianaonoleata1904 Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the user clicks "Edit as Query DSL", the raw DSL is stored in filter.query and passed through scope.alerting.filters. On the server side, buildEsQuery() returns filter.query as is via translateToQuery(), this means no index pattern needed. You're right that Query DSL doesn't need one.

The getAlertsDataViewBase() only affects the KQL part, when the system needs field types to generate the wildcard queries. I'll add a test confirming Query DSL wildcards pass through unchanged.

])(
'should generate wildcard query for keyword fields with KQL pattern: %s',
async (kqlPattern, expectedWildcardValue) => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));

const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
});

savedObjectsClient.create.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);

await createMaintenanceWindow(mockContext, {
data: {
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule as CreateMaintenanceWindowParams['data']['rRule'],
schedule: mockMaintenanceWindow.schedule,
scope: {
alerting: {
kql: `kibana.alert.rule.name: ${kqlPattern}`,
filters: [],
},
},
},
});

const dsl = (savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow).scope!.alerting!
.dsl!;
const parsedDsl = JSON.parse(dsl);

const wildcardClause = parsedDsl.bool.filter[0];
expect(wildcardClause).toEqual({
bool: {
should: [
{
wildcard: {
'kibana.alert.rule.name': {
value: expectedWildcardValue,
},
},
},
],
minimum_should_match: 1,
},
});
}
);

it('should throw if trying to create a maintenance window with invalid scope', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));

Expand Down Expand Up @@ -322,4 +377,60 @@ describe('MaintenanceWindowClient - create', () => {
- [data.categoryIds.1]: expected value to equal [null]"
`);
});
it('should pass Query DSL wildcard filter through unchanged without requiring index pattern', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));

const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
});

savedObjectsClient.create.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);

const wildcardQuery = {
wildcard: {
'kibana.alert.rule.name': {
value: 'example*',
},
},
};

await createMaintenanceWindow(mockContext, {
data: {
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule as CreateMaintenanceWindowParams['data']['rRule'],
schedule: mockMaintenanceWindow.schedule,
scope: {
alerting: {
kql: '',
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
},
$state: {
store: FilterStateStore.APP_STATE,
},
query: wildcardQuery,
},
],
},
},
},
});

const dsl = (savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow).scope!.alerting!
.dsl!;
const parsedDsl = JSON.parse(dsl);

// Query DSL filters are passed through translateToQuery() as filter.query,
// so the wildcard query should appear unchanged in the output — no index pattern needed.
expect(parsedDsl.bool.filter[0]).toEqual(wildcardQuery);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SavedObjectsUtils } from '@kbn/core/server';
import type { Filter } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import { getEsQueryConfig } from '../../../lib/get_es_query_config';
import { getAlertsDataViewBase } from '../../../lib/get_alerts_data_view_base';
import { generateMaintenanceWindowEvents } from '../../lib/generate_maintenance_window_events';
import type { MaintenanceWindowClientContext } from '../../../../common';
import { getScopedQueryErrorMessage } from '../../../../common';
Expand Down Expand Up @@ -39,12 +40,13 @@ export async function createMaintenanceWindow(
}

let scopedQueryWithGeneratedValue = scope?.alerting;
const indexPattern = getAlertsDataViewBase();

try {
if (scope?.alerting) {
const dsl = JSON.stringify(
buildEsQuery(
undefined,
indexPattern,
[{ query: scope.alerting.kql, language: 'kuery' }],
scope.alerting.filters as Filter[],
esQueryConfig
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,79 @@ describe('MaintenanceWindowClient - update', () => {
);
});

it.each([
['test*', 'test*'],
['test rule*', 'test rule*'],
])(
'should generate wildcard query for keyword fields with KQL pattern: %s',
async (kqlPattern, expectedWildcardValue) => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));

const mockMaintenanceWindow = getMockMaintenanceWindow({
schedule: {
custom: {
start: '2023-03-26T00:00:00.000Z',
duration: '1h',
timezone: 'CET',
recurring: {
every: '1w',
occurrences: 5,
},
},
},
events: [{ gte: '2023-03-26T00:00:00.000Z', lte: '2023-03-26T00:12:34.000Z' }],
expirationDate: moment(new Date(firstTimestamp)).tz('UTC').add(2, 'week').toISOString(),
});

savedObjectsClient.get.mockResolvedValue({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);

savedObjectsClient.create.mockResolvedValue({
attributes: {
...mockMaintenanceWindow,
...updatedAttributes,
...updatedMetadata,
},
id: 'test-id',
} as unknown as SavedObject);

await updateMaintenanceWindow(mockContext, {
id: 'test-id',
data: {
scope: {
alerting: {
kql: `kibana.alert.rule.name: ${kqlPattern}`,
filters: [],
},
},
},
});

const dsl = (savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow).scope!.alerting!
.dsl!;
const parsedDsl = JSON.parse(dsl);

const wildcardClause = parsedDsl.bool.filter[0];
expect(wildcardClause).toEqual({
bool: {
should: [
{
wildcard: {
'kibana.alert.rule.name': {
value: expectedWildcardValue,
},
},
},
],
minimum_should_match: 1,
},
});
}
);

it('should remove maintenance window with scope', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { buildEsQuery } from '@kbn/es-query';
import type { MaintenanceWindowClientContext } from '../../../../common';
import { getScopedQueryErrorMessage } from '../../../../common';
import { getEsQueryConfig } from '../../../lib/get_es_query_config';
import { getAlertsDataViewBase } from '../../../lib/get_alerts_data_view_base';
import type { MaintenanceWindow } from '../../types';
import {
generateMaintenanceWindowEvents,
Expand Down Expand Up @@ -57,11 +58,12 @@ async function updateWithOCC(
}

let scopedQueryWithGeneratedValue = scope?.alerting;
const indexPattern = getAlertsDataViewBase();
try {
if (scope?.alerting) {
const dsl = JSON.stringify(
buildEsQuery(
undefined,
indexPattern,
[{ query: scope.alerting.kql, language: 'kuery' }],
scope.alerting.filters as Filter[],
esQueryConfig
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* 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 { ALERT_RULE_NAME, ALERT_STATUS, ALERT_DURATION } from '@kbn/rule-data-utils';
import { getAlertsDataViewBase } from './get_alerts_data_view_base';

describe('getAlertsDataViewBase', () => {
it('should return a DataViewBase with the alerts index pattern', () => {
const dataView = getAlertsDataViewBase();
expect(dataView.title).toBe('.alerts-*');
expect(dataView.fields.length).toBeGreaterThan(0);
});

it('should map keyword fields with correct esTypes', () => {
const dataView = getAlertsDataViewBase();
const ruleNameField = dataView.fields.find((f) => f.name === ALERT_RULE_NAME);

expect(ruleNameField).toBeDefined();
expect(ruleNameField!.type).toBe('string');
expect(ruleNameField!.esTypes).toEqual(['keyword']);
});

it('should map date fields correctly', () => {
const dataView = getAlertsDataViewBase();
const statusField = dataView.fields.find((f) => f.name === ALERT_STATUS);

expect(statusField).toBeDefined();
expect(statusField!.type).toBe('string');
expect(statusField!.esTypes).toEqual(['keyword']);
});

it('should map long fields as number type', () => {
const dataView = getAlertsDataViewBase();
const durationField = dataView.fields.find((f) => f.name === ALERT_DURATION);

expect(durationField).toBeDefined();
expect(durationField!.type).toBe('number');
expect(durationField!.esTypes).toEqual(['long']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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 { alertFieldMap } from '@kbn/alerts-as-data-utils';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably already have some code to generate mappings from the alertFieldMap, since someone has to create the mappings :-). We should find that and reuse it - exporting it or whatever to make it available here.

I'm not sure if different alert indices have different mappings, but I'm 98% sure they can, so I'm not sure this approach covers 100% of the cases. I feel like we may need to have the code that evaluates the MW get passed the mappings. So the alerting code would figure out what alerting indices were going to be queried over, and pass them into the evaluator.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is mappingFromFieldMap() but looks like it generates es index mappings (nested) not a DataViewBase (flat field with esTypes). I don't think we can reuse it directly, but maybe I'm wrong.

However, looks like rule types can register custom fields using IRuleTypeAlerts.mappings.fieldMap. The fix adding getAlertsDataViewBase covers the base alertFieldMap.

I'll look into adding combined maps for this edge case.

Copy link
Copy Markdown
Contributor Author

@georgianaonoleata1904 georgianaonoleata1904 Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @pmuellr , I did a some investigation and for combined maps: we could aggregate all registered IRuleTypeAlerts.mappings.fieldMap entries and merge them with alertFieldMap, but a MW is saved once and can apply to many rule types. I THINK that the correct fix would be to apply your suggestion: generate DSL at evaluation time when the exact rule type is known, but I also think that this changes the scope.alerting contract and feels like a larger follow up.
For now, getAlertsDataViewBase() covers the standard alertFieldMap fields. Maybe we can ship the fix as is and open a follow up issue to track the mappings at evaluation time approach. WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya, even if we just handle all the standard fields, that should be a great first step. Assuming we do that, let's open an issue to later deal with the custom fields. I suspect the easiest thing to do will be to get the fields from ES based on the index pattern we use, which could be a little expensive, so will be tricky to get right :-)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened the follow up issue: #259076
Thanks!

import type { DataViewBase, DataViewFieldBase } from '@kbn/es-query';

const ES_TYPE_TO_KBN_TYPE: Record<string, string> = {
keyword: 'string',
text: 'string',
long: 'number',
integer: 'number',
short: 'number',
byte: 'number',
double: 'number',
float: 'number',
half_float: 'number',
scaled_float: 'number',
date: 'date',
date_range: 'date_range',
boolean: 'boolean',
flattened: 'string',
version: 'string',
unmapped: 'string',
};

export function getAlertsDataViewBase(): DataViewBase {
const fields: DataViewFieldBase[] = Object.entries(alertFieldMap).map(([name, def]) => ({
name,
type: ES_TYPE_TO_KBN_TYPE[def.type] ?? def.type,
esTypes: [def.type],
scripted: false,
}));

return { title: '.alerts-*', fields };
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@
"@kbn/react-query",
"@kbn/shared-ux-router",
"@kbn/response-ops-rules-apis",
"@kbn/core-test-helpers-model-versions"
"@kbn/core-test-helpers-model-versions",
"@kbn/rule-data-utils",
"@kbn/alerts-as-data-utils"
],
"exclude": ["target/**/*"]
}
Loading
Loading