[RAM][Maintenance Window] Maintenance window scoped query frontend changes (#171949)

## Summary
Partially resolves: https://github.com/elastic/kibana/issues/164255,
this is 2/3 of the scoped query changes.

Maintenance window scoped query frontend changes. Adds the ability to
add and edit scoped query for maintenance windows. Due to limitations
with the alerts search bar and each solution fetches AAD fields, we only
allow users to associate scoped query with 1 category (manangement,
o11y, or security solution). The intended usage in this case is for the
user to create multiple maintenance windows if they wish to apply scoped
queries to multiple solutions.

### To test:
go to
`x-pack/plugins/alerting/public/pages/maintenance_windows/constants.ts`
and set `IS_SCOPED_QUERY_ENABLED` to `true`

### Scoped query off, multiple category allowed:

![image](dbf03e8e-f9bd-449c-8d23-0b474fe5a9c4)

### Scoped query on, multiple category disallowed:

![image](368f954a-7671-410b-839b-77f0420f26fa)

### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jiawei Wu 2023-12-04 15:18:33 -08:00 committed by GitHub
parent bf66b25564
commit e4805fc9e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
94 changed files with 2274 additions and 560 deletions

3
.github/CODEOWNERS vendored
View file

@ -9,6 +9,7 @@ x-pack/test/alerting_api_integration/common/plugins/aad @elastic/response-ops
packages/kbn-ace @elastic/platform-deployment-management
x-pack/plugins/actions @elastic/response-ops
x-pack/test/alerting_api_integration/common/plugins/actions_simulators @elastic/response-ops
packages/kbn-actions-types @elastic/response-ops
src/plugins/advanced_settings @elastic/appex-sharedux @elastic/platform-deployment-management
x-pack/packages/ml/aiops_components @elastic/ml-ui
x-pack/plugins/aiops @elastic/ml-ui
@ -19,6 +20,7 @@ x-pack/examples/alerting_example @elastic/response-ops
x-pack/test/functional_with_es_ssl/plugins/alerts @elastic/response-ops
x-pack/plugins/alerting @elastic/response-ops
x-pack/packages/kbn-alerting-state-types @elastic/response-ops
packages/kbn-alerting-types @elastic/response-ops
packages/kbn-alerts-as-data-utils @elastic/response-ops
x-pack/test/alerting_api_integration/common/plugins/alerts_restricted @elastic/response-ops
packages/kbn-alerts-ui-shared @elastic/response-ops
@ -791,6 +793,7 @@ x-pack/plugins/transform @elastic/ml-ui
x-pack/plugins/translations @elastic/kibana-localization
x-pack/examples/triggers_actions_ui_example @elastic/response-ops
x-pack/plugins/triggers_actions_ui @elastic/response-ops
packages/kbn-triggers-actions-ui-types @elastic/response-ops
packages/kbn-ts-projects @elastic/kibana-operations
packages/kbn-ts-type-check-cli @elastic/kibana-operations
packages/kbn-typed-react-router-config @elastic/obs-knowledge-team @elastic/obs-ux-management-team

View file

@ -3,6 +3,7 @@
"advancedSettings": "src/plugins/advanced_settings",
"alerts": "packages/kbn-alerts/src",
"alertsUIShared": "packages/kbn-alerts-ui-shared/src",
"alertingTypes": "packages/kbn-alerting-types",
"apmOss": "src/plugins/apm_oss",
"autocomplete": "packages/kbn-securitysolution-autocomplete/src",
"bfetch": "src/plugins/bfetch",

View file

@ -133,6 +133,7 @@
"@kbn/ace": "link:packages/kbn-ace",
"@kbn/actions-plugin": "link:x-pack/plugins/actions",
"@kbn/actions-simulators-plugin": "link:x-pack/test/alerting_api_integration/common/plugins/actions_simulators",
"@kbn/actions-types": "link:packages/kbn-actions-types",
"@kbn/advanced-settings-plugin": "link:src/plugins/advanced_settings",
"@kbn/aiops-components": "link:x-pack/packages/ml/aiops_components",
"@kbn/aiops-plugin": "link:x-pack/plugins/aiops",
@ -142,6 +143,7 @@
"@kbn/alerting-fixture-plugin": "link:x-pack/test/functional_with_es_ssl/plugins/alerts",
"@kbn/alerting-plugin": "link:x-pack/plugins/alerting",
"@kbn/alerting-state-types": "link:x-pack/packages/kbn-alerting-state-types",
"@kbn/alerting-types": "link:packages/kbn-alerting-types",
"@kbn/alerts-as-data-utils": "link:packages/kbn-alerts-as-data-utils",
"@kbn/alerts-restricted-fixtures-plugin": "link:x-pack/test/alerting_api_integration/common/plugins/alerts_restricted",
"@kbn/alerts-ui-shared": "link:packages/kbn-alerts-ui-shared",
@ -782,6 +784,7 @@
"@kbn/translations-plugin": "link:x-pack/plugins/translations",
"@kbn/triggers-actions-ui-example-plugin": "link:x-pack/examples/triggers_actions_ui_example",
"@kbn/triggers-actions-ui-plugin": "link:x-pack/plugins/triggers_actions_ui",
"@kbn/triggers-actions-ui-types": "link:packages/kbn-triggers-actions-ui-types",
"@kbn/typed-react-router-config": "link:packages/kbn-typed-react-router-config",
"@kbn/ui-actions-browser": "link:packages/kbn-ui-actions-browser",
"@kbn/ui-actions-enhanced-examples-plugin": "link:x-pack/examples/ui_actions_enhanced_examples",

View file

@ -0,0 +1,3 @@
# @kbn/actions-types
Empty package generated by @kbn/generate

View file

@ -0,0 +1,9 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export * from './rewrite_request_case_types';

View file

@ -0,0 +1,13 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-actions-types'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/actions-types",
"owner": "@elastic/response-ops"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/actions-types",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,51 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
type RenameActionToConnector<K extends string> = K extends `actionTypeId`
? `connectorTypeId`
: K extends `actionId`
? `connectorId`
: K;
export type AsApiContract<T> = {
[K in keyof T as CamelToSnake<RenameActionToConnector<Extract<K, string>>>]: K extends 'frequency'
? AsApiContract<T[K]>
: T[K];
};
export type RewriteRequestCase<T> = (requested: AsApiContract<T>) => T;
export type RewriteResponseCase<T> = (
responded: T
) => T extends Array<infer Item> ? Array<AsApiContract<Item>> : AsApiContract<T>;
/**
* This type maps Camel Case strings into their Snake Case version.
* This is achieved by checking each character and, if it is an uppercase character, it is mapped to an
* underscore followed by a lowercase one.
*
* The reason there are two ternaries is that, for perfformance reasons, TS limits its
* character parsing to ~15 characters.
* To get around this we use the second turnery to parse 2 characters at a time, which allows us to support
* strings that are 30 characters long.
*
* If you get the TS #2589 error ("Type instantiation is excessively deep and possibly infinite") then most
* likely you have a string that's longer than 30 characters.
* Address this by reducing the length if possible, otherwise, you'll need to add a 3rd ternary which
* parses 3 chars at a time :grimace:
*
* For more details see this PR comment: https://github.com/microsoft/TypeScript/pull/40336#issuecomment-686723087
*/
type CamelToSnake<T extends string> = string extends T
? string
: T extends `${infer C0}${infer C1}${infer R}`
? `${C0 extends Uppercase<C0> ? '_' : ''}${Lowercase<C0>}${C1 extends Uppercase<C1>
? '_'
: ''}${Lowercase<C1>}${CamelToSnake<R>}`
: T extends `${infer C0}${infer R}`
? `${C0 extends Uppercase<C0> ? '_' : ''}${Lowercase<C0>}${CamelToSnake<R>}`
: '';

View file

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": []
}

View file

@ -0,0 +1,3 @@
# @kbn/alerting-types
Empty package generated by @kbn/generate

View file

@ -0,0 +1,12 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export interface ActionGroup<ActionGroupIds extends string> {
id: ActionGroupIds;
name: string;
}

View file

@ -0,0 +1,21 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { ActionGroup } from './action_group_types';
export const RecoveredActionGroup: Readonly<ActionGroup<'recovered'>> = Object.freeze({
id: 'recovered',
name: i18n.translate('alertingTypes.builtinActionGroups.recovered', {
defaultMessage: 'Recovered',
}),
});
export type DefaultActionGroupId = 'default';
export type RecoveredActionGroupId = typeof RecoveredActionGroup['id'];

View file

@ -0,0 +1,11 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export * from './builtin_action_groups_types';
export * from './rule_type';
export * from './action_group_types';

View file

@ -0,0 +1,13 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-alerting-types'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/alerting-types",
"owner": "@elastic/response-ops"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/alerting-types",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,55 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import type { LicenseType } from '@kbn/licensing-plugin/common/types';
import type { RecoveredActionGroupId, DefaultActionGroupId } from './builtin_action_groups_types';
import { ActionGroup } from './action_group_types';
interface ConsumerPrivileges {
read: boolean;
all: boolean;
}
export interface ActionVariable {
name: string;
description: string;
deprecated?: boolean;
useWithTripleBracesInTemplates?: boolean;
usesPublicBaseUrl?: boolean;
}
export interface RuleType<
ActionGroupIds extends Exclude<string, RecoveredActionGroupId> = DefaultActionGroupId,
RecoveryActionGroupId extends string = RecoveredActionGroupId
> {
id: string;
name: string;
actionGroups: Array<ActionGroup<ActionGroupIds>>;
recoveryActionGroup: ActionGroup<RecoveryActionGroupId>;
actionVariables: {
context: ActionVariable[];
state: ActionVariable[];
params: ActionVariable[];
};
defaultActionGroupId: ActionGroupIds;
category: string;
producer: string;
minimumLicenseRequired: LicenseType;
isExportable: boolean;
ruleTaskTimeout?: string;
defaultScheduleInterval?: string;
doesSetRecoveryContext?: boolean;
enabledInLicense: boolean;
authorizedConsumers: Record<string, ConsumerPrivileges>;
}
export type ActionGroupIdsOf<T> = T extends ActionGroup<infer groups>
? groups
: T extends Readonly<ActionGroup<infer groups>>
? groups
: never;

View file

@ -0,0 +1,22 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/i18n",
"@kbn/licensing-plugin",
]
}

View file

@ -10,3 +10,8 @@ export { AlertLifecycleStatusBadge } from './src/alert_lifecycle_status_badge';
export type { AlertLifecycleStatusBadgeProps } from './src/alert_lifecycle_status_badge';
export { MaintenanceWindowCallout } from './src/maintenance_window_callout';
export { AddMessageVariables } from './src/add_message_variables';
export * from './src/alerts_search_bar/hooks';
export * from './src/alerts_search_bar/apis';
export { AlertsSearchBar } from './src/alerts_search_bar';
export type { AlertsSearchBarProps } from './src/alerts_search_bar/types';

View file

@ -22,7 +22,7 @@ import {
EuiToolTip,
EuiSelectableOption,
} from '@elastic/eui';
import { ActionVariable } from '@kbn/alerting-plugin/common';
import type { ActionVariable } from '@kbn/alerting-types';
import './add_message_variables.scss';
import { TruncatedText } from './truncated_text';
import * as i18n from './translations';

View file

@ -0,0 +1,26 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import type { HttpStart } from '@kbn/core/public';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import { BASE_RAC_ALERTS_API_PATH, EMPTY_AAD_FIELDS } from '../constants';
export async function fetchAadFields({
http,
ruleTypeId,
}: {
http: HttpStart;
ruleTypeId?: string;
}): Promise<DataViewField[]> {
if (!ruleTypeId) return EMPTY_AAD_FIELDS;
const fields = await http.get<DataViewField[]>(`${BASE_RAC_ALERTS_API_PATH}/aad_fields`, {
query: { ruleTypeId },
});
return fields;
}

View file

@ -0,0 +1,28 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { ValidFeatureId } from '@kbn/rule-data-utils';
import { HttpSetup } from '@kbn/core/public';
import { FieldSpec } from '@kbn/data-views-plugin/common';
import { BASE_RAC_ALERTS_API_PATH } from '../constants';
export async function fetchAlertFields({
http,
featureIds,
}: {
http: HttpSetup;
featureIds: ValidFeatureId[];
}): Promise<FieldSpec[]> {
const { fields: alertFields = [] } = await http.get<{ fields: FieldSpec[] }>(
`${BASE_RAC_ALERTS_API_PATH}/browser_fields`,
{
query: { featureIds },
}
);
return alertFields;
}

View file

@ -0,0 +1,26 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { HttpSetup } from '@kbn/core/public';
import { BASE_RAC_ALERTS_API_PATH } from '../constants';
export async function fetchAlertIndexNames({
http,
features,
}: {
http: HttpSetup;
features: string;
}): Promise<string[]> {
const { index_name: indexNamesStr = [] } = await http.get<{ index_name: string[] }>(
`${BASE_RAC_ALERTS_API_PATH}/index`,
{
query: { features },
}
);
return indexNamesStr;
}

View file

@ -0,0 +1,53 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { HttpSetup } from '@kbn/core/public';
import type { AsApiContract, RewriteRequestCase } from '@kbn/actions-types';
import type { RuleType } from '@kbn/triggers-actions-ui-types';
import { BASE_ALERTING_API_PATH } from '../constants';
const rewriteResponseRes = (results: Array<AsApiContract<RuleType>>): RuleType[] => {
return results.map((item) => rewriteBodyReq(item));
};
const rewriteBodyReq: RewriteRequestCase<RuleType> = ({
enabled_in_license: enabledInLicense,
recovery_action_group: recoveryActionGroup,
action_groups: actionGroups,
default_action_group_id: defaultActionGroupId,
minimum_license_required: minimumLicenseRequired,
action_variables: actionVariables,
authorized_consumers: authorizedConsumers,
rule_task_timeout: ruleTaskTimeout,
does_set_recovery_context: doesSetRecoveryContext,
default_schedule_interval: defaultScheduleInterval,
has_alerts_mappings: hasAlertsMappings,
has_fields_for_a_a_d: hasFieldsForAAD,
...rest
}: AsApiContract<RuleType>) => ({
enabledInLicense,
recoveryActionGroup,
actionGroups,
defaultActionGroupId,
minimumLicenseRequired,
actionVariables,
authorizedConsumers,
ruleTaskTimeout,
doesSetRecoveryContext,
defaultScheduleInterval,
hasAlertsMappings,
hasFieldsForAAD,
...rest,
});
export async function fetchRuleTypes({ http }: { http: HttpSetup }): Promise<RuleType[]> {
const res = await http.get<Array<AsApiContract<RuleType<string, string>>>>(
`${BASE_ALERTING_API_PATH}/rule_types`
);
return rewriteResponseRes(res);
}

View file

@ -0,0 +1,12 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export * from './fetch_aad_fields';
export * from './fetch_alert_fields';
export * from './fetch_alert_index_names';
export * from './fetch_rule_types';

View file

@ -0,0 +1,15 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
export const NO_INDEX_PATTERNS: DataView[] = [];
export const EMPTY_AAD_FIELDS: DataViewField[] = [];
export const ALERTS_FEATURE_ID = 'alerts';
export const BASE_ALERTING_API_PATH = '/api/alerting';
export const BASE_RAC_ALERTS_API_PATH = '/internal/rac/alerts';

View file

@ -0,0 +1,11 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export * from './use_alert_data_view';
export * from './use_load_rule_types_query';
export * from './use_rule_aad_fields';

View file

@ -0,0 +1,167 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/common';
import { AlertConsumers, ValidFeatureId } from '@kbn/rule-data-utils';
import type { ToastsStart, HttpStart } from '@kbn/core/public';
import { useQuery } from '@tanstack/react-query';
import { fetchAlertIndexNames } from '../apis/fetch_alert_index_names';
import { fetchAlertFields } from '../apis/fetch_alert_fields';
export interface UseAlertDataViewResult {
dataViews?: DataView[];
loading: boolean;
}
export interface UseAlertDataViewProps {
featureIds: ValidFeatureId[];
http: HttpStart;
dataViewsService: DataViewsContract;
toasts: ToastsStart;
}
export function useAlertDataView(props: UseAlertDataViewProps): UseAlertDataViewResult {
const { http, dataViewsService, toasts, featureIds } = props;
const [dataViews, setDataViews] = useState<DataView[]>([]);
const features = featureIds.sort().join(',');
const isOnlySecurity = featureIds.length === 1 && featureIds.includes(AlertConsumers.SIEM);
const hasSecurityAndO11yFeatureIds =
featureIds.length > 1 && featureIds.includes(AlertConsumers.SIEM);
const hasNoSecuritySolution =
featureIds.length > 0 && !isOnlySecurity && !hasSecurityAndO11yFeatureIds;
const queryIndexNameFn = () => {
return fetchAlertIndexNames({ http, features });
};
const queryAlertFieldsFn = () => {
return fetchAlertFields({ http, featureIds });
};
const onErrorFn = () => {
toasts.addDanger(
i18n.translate('alertsUIShared.hooks.useAlertDataView.useAlertDataMessage', {
defaultMessage: 'Unable to load alert data view',
})
);
};
const {
data: indexNames,
isSuccess: isIndexNameSuccess,
isInitialLoading: isIndexNameInitialLoading,
isLoading: isIndexNameLoading,
} = useQuery({
queryKey: ['loadAlertIndexNames', features],
queryFn: queryIndexNameFn,
onError: onErrorFn,
refetchOnWindowFocus: false,
enabled: featureIds.length > 0 && !hasSecurityAndO11yFeatureIds,
});
const {
data: alertFields,
isSuccess: isAlertFieldsSuccess,
isInitialLoading: isAlertFieldsInitialLoading,
isLoading: isAlertFieldsLoading,
} = useQuery({
queryKey: ['loadAlertFields', features],
queryFn: queryAlertFieldsFn,
onError: onErrorFn,
refetchOnWindowFocus: false,
enabled: hasNoSecuritySolution,
});
useEffect(() => {
return () => {
dataViews.map((dv) => {
dataViewsService.clearInstanceCache(dv.id);
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataViews]);
// FUTURE ENGINEER this useEffect is for security solution user since
// we are using the user privilege to access the security alert index
useEffect(() => {
async function createDataView() {
const localDataview = await dataViewsService.create({
title: (indexNames ?? []).join(','),
allowNoIndex: true,
});
setDataViews([localDataview]);
}
if (isOnlySecurity && isIndexNameSuccess) {
createDataView();
}
}, [dataViewsService, indexNames, isIndexNameSuccess, isOnlySecurity]);
// FUTURE ENGINEER this useEffect is for o11y and stack solution user since
// we are using the kibana user privilege to access the alert index
useEffect(() => {
if (
indexNames &&
alertFields &&
!isOnlySecurity &&
isAlertFieldsSuccess &&
isIndexNameSuccess
) {
setDataViews([
{
title: (indexNames ?? []).join(','),
fieldFormatMap: {},
fields: (alertFields ?? [])?.map((field) => {
return {
...field,
...(field.esTypes && field.esTypes.includes('flattened') ? { type: 'string' } : {}),
};
}),
},
] as unknown as DataView[]);
}
}, [
alertFields,
dataViewsService,
indexNames,
isIndexNameSuccess,
isOnlySecurity,
isAlertFieldsSuccess,
]);
return useMemo(
() => ({
dataViews,
loading:
featureIds.length === 0 || hasSecurityAndO11yFeatureIds
? false
: isOnlySecurity
? isIndexNameInitialLoading || isIndexNameLoading
: isIndexNameInitialLoading ||
isIndexNameLoading ||
isAlertFieldsInitialLoading ||
isAlertFieldsLoading,
}),
[
dataViews,
featureIds.length,
hasSecurityAndO11yFeatureIds,
isOnlySecurity,
isIndexNameInitialLoading,
isIndexNameLoading,
isAlertFieldsInitialLoading,
isAlertFieldsLoading,
]
);
}

View file

@ -0,0 +1,85 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { useQuery } from '@tanstack/react-query';
import type { RuleType, RuleTypeIndex } from '@kbn/triggers-actions-ui-types';
import type { ToastsStart, HttpStart } from '@kbn/core/public';
import { ALERTS_FEATURE_ID } from '../constants';
import { fetchRuleTypes } from '../apis/fetch_rule_types';
export interface UseLoadRuleTypesQueryProps {
filteredRuleTypes: string[];
enabled?: boolean;
http: HttpStart;
toasts: ToastsStart;
}
const getFilteredIndex = (data: Array<RuleType<string, string>>, filteredRuleTypes: string[]) => {
const index: RuleTypeIndex = new Map();
for (const ruleType of data) {
index.set(ruleType.id, ruleType);
}
let filteredIndex = index;
if (filteredRuleTypes?.length) {
filteredIndex = new Map(
[...index].filter(([k, v]) => {
return filteredRuleTypes.includes(v.id);
})
);
}
return filteredIndex;
};
export const useLoadRuleTypesQuery = (props: UseLoadRuleTypesQueryProps) => {
const { filteredRuleTypes, enabled = true, http, toasts } = props;
const queryFn = () => {
return fetchRuleTypes({ http });
};
const onErrorFn = () => {
toasts.addDanger(
i18n.translate('alertsUIShared.hooks.useLoadRuleTypesQuery.unableToLoadRuleTypesMessage', {
defaultMessage: 'Unable to load rule types',
})
);
};
const { data, isSuccess, isFetching, isInitialLoading, isLoading } = useQuery({
queryKey: ['loadRuleTypes'],
queryFn,
onError: onErrorFn,
refetchOnWindowFocus: false,
enabled,
});
const filteredIndex = data ? getFilteredIndex(data, filteredRuleTypes) : new Map();
const hasAnyAuthorizedRuleType = filteredIndex.size > 0;
const authorizedRuleTypes = [...filteredIndex.values()];
const authorizedToCreateAnyRules = authorizedRuleTypes.some(
(ruleType) => ruleType.authorizedConsumers[ALERTS_FEATURE_ID]?.all
);
const authorizedToReadAnyRules =
authorizedToCreateAnyRules ||
authorizedRuleTypes.some((ruleType) => ruleType.authorizedConsumers[ALERTS_FEATURE_ID]?.read);
return {
ruleTypesState: {
initialLoad: isLoading || isInitialLoading,
isLoading: isLoading || isFetching,
data: filteredIndex,
},
hasAnyAuthorizedRuleType,
authorizedRuleTypes,
authorizedToReadAnyRules,
authorizedToCreateAnyRules,
isSuccess,
};
};

View file

@ -0,0 +1,62 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import type { ToastsStart, HttpStart } from '@kbn/core/public';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import { EMPTY_AAD_FIELDS } from '../constants';
import { fetchAadFields } from '../apis/fetch_aad_fields';
export interface UseRuleAADFieldsProps {
ruleTypeId?: string;
http: HttpStart;
toasts: ToastsStart;
}
export interface UseRuleAADFieldsResult {
aadFields: DataViewField[];
loading: boolean;
}
export function useRuleAADFields(props: UseRuleAADFieldsProps): UseRuleAADFieldsResult {
const { ruleTypeId, http, toasts } = props;
const queryAadFieldsFn = () => {
return fetchAadFields({ http, ruleTypeId });
};
const onErrorFn = () => {
toasts.addDanger(
i18n.translate('alertsUIShared.hooks.useRuleAADFields.errorMessage', {
defaultMessage: 'Unable to load alert fields per rule type',
})
);
};
const {
data: aadFields = EMPTY_AAD_FIELDS,
isInitialLoading,
isLoading,
} = useQuery({
queryKey: ['loadAlertAadFieldsPerRuleType', ruleTypeId],
queryFn: queryAadFieldsFn,
onError: onErrorFn,
refetchOnWindowFocus: false,
enabled: ruleTypeId !== undefined,
});
return useMemo(
() => ({
aadFields,
loading: ruleTypeId === undefined ? false : isInitialLoading || isLoading,
}),
[aadFields, isInitialLoading, isLoading, ruleTypeId]
);
}

View file

@ -0,0 +1,126 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { useCallback, useState } from 'react';
import type { Query, TimeRange } from '@kbn/es-query';
import type { SuggestionsAbstraction } from '@kbn/unified-search-plugin/public/typeahead/suggestions_component';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { NO_INDEX_PATTERNS } from './constants';
import { SEARCH_BAR_PLACEHOLDER } from './translations';
import type { AlertsSearchBarProps, QueryLanguageType } from './types';
import { useAlertDataView } from './hooks/use_alert_data_view';
import { useRuleAADFields } from './hooks/use_rule_aad_fields';
import { useLoadRuleTypesQuery } from './hooks/use_load_rule_types_query';
const SA_ALERTS = { type: 'alerts', fields: {} } as SuggestionsAbstraction;
export const AlertsSearchBar = ({
appName,
disableQueryLanguageSwitcher = false,
featureIds,
ruleTypeId,
query,
filters,
onQueryChange,
onQuerySubmit,
onFiltersUpdated,
rangeFrom,
rangeTo,
showFilterBar = false,
showDatePicker = true,
showSubmitButton = true,
placeholder = SEARCH_BAR_PLACEHOLDER,
submitOnBlur = false,
http,
toasts,
unifiedSearchBar,
dataViewsService,
}: AlertsSearchBarProps) => {
const [queryLanguage, setQueryLanguage] = useState<QueryLanguageType>('kuery');
const { dataViews, loading } = useAlertDataView({
featureIds: featureIds ?? [],
http,
toasts,
dataViewsService,
});
const { aadFields, loading: fieldsLoading } = useRuleAADFields({
ruleTypeId,
http,
toasts,
});
const indexPatterns =
ruleTypeId && aadFields?.length ? [{ title: ruleTypeId, fields: aadFields }] : dataViews;
const ruleType = useLoadRuleTypesQuery({
filteredRuleTypes: ruleTypeId !== undefined ? [ruleTypeId] : [],
enabled: ruleTypeId !== undefined,
http,
toasts,
});
const isSecurity =
(featureIds && featureIds.length === 1 && featureIds.includes(AlertConsumers.SIEM)) ||
(ruleType &&
ruleTypeId &&
ruleType.ruleTypesState.data.get(ruleTypeId)?.producer === AlertConsumers.SIEM);
const onSearchQuerySubmit = useCallback(
({ dateRange, query: nextQuery }: { dateRange: TimeRange; query?: Query }) => {
onQuerySubmit({
dateRange,
query: typeof nextQuery?.query === 'string' ? nextQuery.query : undefined,
});
setQueryLanguage((nextQuery?.language ?? 'kuery') as QueryLanguageType);
},
[onQuerySubmit, setQueryLanguage]
);
const onSearchQueryChange = useCallback(
({ dateRange, query: nextQuery }: { dateRange: TimeRange; query?: Query }) => {
onQueryChange?.({
dateRange,
query: typeof nextQuery?.query === 'string' ? nextQuery.query : undefined,
});
setQueryLanguage((nextQuery?.language ?? 'kuery') as QueryLanguageType);
},
[onQueryChange, setQueryLanguage]
);
const onRefresh = ({ dateRange }: { dateRange: TimeRange }) => {
onQuerySubmit({
dateRange,
});
};
return unifiedSearchBar({
appName,
disableQueryLanguageSwitcher,
// @ts-expect-error - DataView fields prop and SearchBar indexPatterns props are overly broad
indexPatterns: loading || fieldsLoading ? NO_INDEX_PATTERNS : indexPatterns,
placeholder,
query: { query: query ?? '', language: queryLanguage },
filters,
dateRangeFrom: rangeFrom,
dateRangeTo: rangeTo,
displayStyle: 'inPage',
showFilterBar,
onQuerySubmit: onSearchQuerySubmit,
onFiltersUpdated,
onRefresh,
showDatePicker,
showQueryInput: true,
saveQueryMenuVisibility: 'allowed_by_app_privilege',
showSubmitButton,
submitOnBlur,
onQueryChange: onSearchQueryChange,
suggestionsAbstraction: isSecurity ? undefined : SA_ALERTS,
});
};
// eslint-disable-next-line import/no-default-export
export { AlertsSearchBar as default };

View file

@ -0,0 +1,16 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
export const SEARCH_BAR_PLACEHOLDER = i18n.translate(
'alertsUIShared.component.alertsSearchBar.placeholder',
{
defaultMessage: 'Search alerts (e.g. kibana.alert.evaluation.threshold > 75)',
}
);

View file

@ -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 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 or the Server
* Side Public License, v 1.
*/
import type { Filter } from '@kbn/es-query';
import type { ValidFeatureId } from '@kbn/rule-data-utils';
import type { ToastsStart, HttpStart } from '@kbn/core/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { DataViewsContract } from '@kbn/data-views-plugin/common';
export type QueryLanguageType = 'lucene' | 'kuery';
export interface AlertsSearchBarProps {
appName: string;
disableQueryLanguageSwitcher?: boolean;
featureIds: ValidFeatureId[];
rangeFrom?: string;
rangeTo?: string;
query?: string;
filters?: Filter[];
showFilterBar?: boolean;
showDatePicker?: boolean;
showSubmitButton?: boolean;
placeholder?: string;
submitOnBlur?: boolean;
ruleTypeId?: string;
onQueryChange?: (query: {
dateRange: { from: string; to: string; mode?: 'absolute' | 'relative' };
query?: string;
}) => void;
onQuerySubmit: (query: {
dateRange: { from: string; to: string; mode?: 'absolute' | 'relative' };
query?: string;
}) => void;
onFiltersUpdated?: (filters: Filter[]) => void;
http: HttpStart;
toasts: ToastsStart;
unifiedSearchBar: UnifiedSearchPublicPluginStart['ui']['SearchBar'];
dataViewsService: DataViewsContract;
}

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { AsApiContract } from '@kbn/actions-plugin/common';
import { AsApiContract } from '@kbn/actions-types';
import type { KibanaServices, MaintenanceWindow } from './types';
const rewriteMaintenanceWindowRes = ({

View file

@ -0,0 +1,9 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export const MAINTENANCE_WINDOW_FEATURE_ID = 'maintenanceWindow';

View file

@ -10,7 +10,7 @@ import React from 'react';
import { I18nProvider } from '@kbn/i18n-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, waitFor, cleanup, screen } from '@testing-library/react';
import { MAINTENANCE_WINDOW_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { MAINTENANCE_WINDOW_FEATURE_ID } from './constants';
import { MaintenanceWindowCallout } from '.';
import { fetchActiveMaintenanceWindows } from './api';
import {

View file

@ -12,20 +12,11 @@ import { EuiCallOut } from '@elastic/eui';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common';
import { MaintenanceWindowStatus, KibanaServices } from './types';
import { useFetchActiveMaintenanceWindows } from './use_fetch_active_maintenance_windows';
const MAINTENANCE_WINDOW_FEATURE_ID = 'maintenanceWindow';
const MAINTENANCE_WINDOW_RUNNING_DESCRIPTION = i18n.translate(
'alertsUIShared.maintenanceWindowCallout.maintenanceWindowActiveDescription',
{
defaultMessage: 'Rule notifications are stopped while maintenance windows are running.',
}
);
const MAINTENANCE_WINDOW_NO_CATEGORY_TITLE = i18n.translate(
'alertsUIShared.maintenanceWindowCallout.maintenanceWindowActiveNoCategories',
{
defaultMessage: 'One or more maintenance windows are running',
}
);
import { MAINTENANCE_WINDOW_FEATURE_ID } from './constants';
import {
MAINTENANCE_WINDOW_NO_CATEGORY_TITLE,
MAINTENANCE_WINDOW_RUNNING_DESCRIPTION,
} from './translations';
const maintenanceWindowTwoCategoryNames = (names: string[]) =>
i18n.translate('alertsUIShared.maintenanceWindowCallout.maintenanceWindowTwoCategoryNames', {

View file

@ -6,24 +6,23 @@
* Side Public License, v 1.
*/
import type { AsApiContract } from '@kbn/alerting-plugin/server/routes/lib';
import { MaintenanceWindow, MaintenanceWindowStatus } from './types';
import { MaintenanceWindowStatus } from './types';
export const RUNNING_MAINTENANCE_WINDOW_1: Partial<MaintenanceWindow> = {
export const RUNNING_MAINTENANCE_WINDOW_1 = {
title: 'Running maintenance window 1',
id: '63057284-ac31-42ba-fe22-adfe9732e5ae',
status: MaintenanceWindowStatus.Running,
events: [{ gte: '2023-04-20T16:27:30.753Z', lte: '2023-04-20T16:57:30.753Z' }],
};
export const RUNNING_MAINTENANCE_WINDOW_2: Partial<MaintenanceWindow> = {
export const RUNNING_MAINTENANCE_WINDOW_2 = {
title: 'Running maintenance window 2',
id: '45894340-df98-11ed-ac81-bfcb4982b4fd',
status: MaintenanceWindowStatus.Running,
events: [{ gte: '2023-04-20T16:47:42.871Z', lte: '2023-04-20T17:11:32.192Z' }],
};
export const RECURRING_RUNNING_MAINTENANCE_WINDOW: Partial<AsApiContract<MaintenanceWindow>> = {
export const RECURRING_RUNNING_MAINTENANCE_WINDOW = {
title: 'Recurring running maintenance window',
id: 'e2228300-e9ad-11ed-ba37-db17c6e6182b',
status: MaintenanceWindowStatus.Running,
@ -42,7 +41,7 @@ export const RECURRING_RUNNING_MAINTENANCE_WINDOW: Partial<AsApiContract<Mainten
},
};
export const UPCOMING_MAINTENANCE_WINDOW: Partial<MaintenanceWindow> = {
export const UPCOMING_MAINTENANCE_WINDOW = {
title: 'Upcoming maintenance window',
id: '5eafe070-e030-11ed-ac81-bfcb4982b4fd',
status: MaintenanceWindowStatus.Upcoming,

View file

@ -0,0 +1,22 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
export const MAINTENANCE_WINDOW_RUNNING_DESCRIPTION = i18n.translate(
'alertsUIShared.maintenanceWindowCallout.maintenanceWindowActiveDescription',
{
defaultMessage: 'Rule notifications are stopped while maintenance windows are running.',
}
);
export const MAINTENANCE_WINDOW_NO_CATEGORY_TITLE = i18n.translate(
'alertsUIShared.maintenanceWindowCallout.maintenanceWindowActiveNoCategories',
{
defaultMessage: 'One or more maintenance windows are running',
}
);

View file

@ -20,9 +20,13 @@
"@kbn/rule-data-utils",
"@kbn/core",
"@kbn/i18n-react",
"@kbn/alerting-plugin",
"@kbn/rrule",
"@kbn/actions-plugin",
"@kbn/core-application-common"
"@kbn/core-application-common",
"@kbn/triggers-actions-ui-types",
"@kbn/alerting-types",
"@kbn/actions-types",
"@kbn/data-views-plugin",
"@kbn/unified-search-plugin",
"@kbn/es-query",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/triggers-actions-ui-types
Empty package generated by @kbn/generate

View file

@ -0,0 +1,20 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import type { ActionVariable } from '@kbn/alerting-types';
export const REQUIRED_ACTION_VARIABLES = ['params'] as const;
export const CONTEXT_ACTION_VARIABLES = ['context'] as const;
export const OPTIONAL_ACTION_VARIABLES = [...CONTEXT_ACTION_VARIABLES, 'state'] as const;
type AsActionVariables<Keys extends string> = {
[Req in Keys]: ActionVariable[];
};
export type ActionVariables = AsActionVariables<typeof REQUIRED_ACTION_VARIABLES[number]> &
Partial<AsActionVariables<typeof OPTIONAL_ACTION_VARIABLES[number]>>;

View file

@ -0,0 +1,10 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export * from './rule_types';
export * from './action_variable_types';

View file

@ -0,0 +1,13 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-triggers-actions-ui-types'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/triggers-actions-ui-types",
"owner": "@elastic/response-ops"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/triggers-actions-ui-types",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,35 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import type { RuleType as CommonRuleType } from '@kbn/alerting-types';
import type { ActionVariables } from './action_variable_types';
export interface RuleType<
ActionGroupIds extends string = string,
RecoveryActionGroupId extends string = string
> extends Pick<
CommonRuleType<ActionGroupIds, RecoveryActionGroupId>,
| 'id'
| 'name'
| 'actionGroups'
| 'producer'
| 'minimumLicenseRequired'
| 'recoveryActionGroup'
| 'defaultActionGroupId'
| 'ruleTaskTimeout'
| 'defaultScheduleInterval'
| 'doesSetRecoveryContext'
> {
actionVariables: ActionVariables;
authorizedConsumers: Record<string, { read: boolean; all: boolean }>;
enabledInLicense: boolean;
hasFieldsForAAD?: boolean;
hasAlertsMappings?: boolean;
}
export type RuleTypeIndex = Map<string, RuleType>;

View file

@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/alerting-types"
]
}

View file

@ -12,6 +12,8 @@
"@kbn/actions-plugin/*": ["x-pack/plugins/actions/*"],
"@kbn/actions-simulators-plugin": ["x-pack/test/alerting_api_integration/common/plugins/actions_simulators"],
"@kbn/actions-simulators-plugin/*": ["x-pack/test/alerting_api_integration/common/plugins/actions_simulators/*"],
"@kbn/actions-types": ["packages/kbn-actions-types"],
"@kbn/actions-types/*": ["packages/kbn-actions-types/*"],
"@kbn/advanced-settings-plugin": ["src/plugins/advanced_settings"],
"@kbn/advanced-settings-plugin/*": ["src/plugins/advanced_settings/*"],
"@kbn/aiops-components": ["x-pack/packages/ml/aiops_components"],
@ -32,6 +34,8 @@
"@kbn/alerting-plugin/*": ["x-pack/plugins/alerting/*"],
"@kbn/alerting-state-types": ["x-pack/packages/kbn-alerting-state-types"],
"@kbn/alerting-state-types/*": ["x-pack/packages/kbn-alerting-state-types/*"],
"@kbn/alerting-types": ["packages/kbn-alerting-types"],
"@kbn/alerting-types/*": ["packages/kbn-alerting-types/*"],
"@kbn/alerts-as-data-utils": ["packages/kbn-alerts-as-data-utils"],
"@kbn/alerts-as-data-utils/*": ["packages/kbn-alerts-as-data-utils/*"],
"@kbn/alerts-restricted-fixtures-plugin": ["x-pack/test/alerting_api_integration/common/plugins/alerts_restricted"],
@ -1576,6 +1580,8 @@
"@kbn/triggers-actions-ui-example-plugin/*": ["x-pack/examples/triggers_actions_ui_example/*"],
"@kbn/triggers-actions-ui-plugin": ["x-pack/plugins/triggers_actions_ui"],
"@kbn/triggers-actions-ui-plugin/*": ["x-pack/plugins/triggers_actions_ui/*"],
"@kbn/triggers-actions-ui-types": ["packages/kbn-triggers-actions-ui-types"],
"@kbn/triggers-actions-ui-types/*": ["packages/kbn-triggers-actions-ui-types/*"],
"@kbn/ts-projects": ["packages/kbn-ts-projects"],
"@kbn/ts-projects/*": ["packages/kbn-ts-projects/*"],
"@kbn/ts-type-check-cli": ["packages/kbn-ts-type-check-cli"],

View file

@ -5,46 +5,4 @@
* 2.0.
*/
type RenameActionToConnector<K extends string> = K extends `actionTypeId`
? `connectorTypeId`
: K extends `actionId`
? `connectorId`
: K;
export type AsApiContract<T> = {
[K in keyof T as CamelToSnake<RenameActionToConnector<Extract<K, string>>>]: K extends 'frequency'
? AsApiContract<T[K]>
: T[K];
};
export type RewriteRequestCase<T> = (requested: AsApiContract<T>) => T;
export type RewriteResponseCase<T> = (
responded: T
) => T extends Array<infer Item> ? Array<AsApiContract<Item>> : AsApiContract<T>;
/**
* This type maps Camel Case strings into their Snake Case version.
* This is achieved by checking each character and, if it is an uppercase character, it is mapped to an
* underscore followed by a lowercase one.
*
* The reason there are two ternaries is that, for perfformance reasons, TS limits its
* character parsing to ~15 characters.
* To get around this we use the second turnery to parse 2 characters at a time, which allows us to support
* strings that are 30 characters long.
*
* If you get the TS #2589 error ("Type instantiation is excessively deep and possibly infinite") then most
* likely you have a string that's longer than 30 characters.
* Address this by reducing the length if possible, otherwise, you'll need to add a 3rd ternary which
* parses 3 chars at a time :grimace:
*
* For more details see this PR comment: https://github.com/microsoft/TypeScript/pull/40336#issuecomment-686723087
*/
type CamelToSnake<T extends string> = string extends T
? string
: T extends `${infer C0}${infer C1}${infer R}`
? `${C0 extends Uppercase<C0> ? '_' : ''}${Lowercase<C0>}${C1 extends Uppercase<C1>
? '_'
: ''}${Lowercase<C1>}${CamelToSnake<R>}`
: T extends `${infer C0}${infer R}`
? `${C0 extends Uppercase<C0> ? '_' : ''}${Lowercase<C0>}${CamelToSnake<R>}`
: '';
export type { AsApiContract, RewriteRequestCase, RewriteResponseCase } from '@kbn/actions-types';

View file

@ -44,6 +44,7 @@
"@kbn/core-elasticsearch-server-mocks",
"@kbn/core-logging-server-mocks",
"@kbn/serverless",
"@kbn/actions-types"
],
"exclude": [
"target/**/*",

View file

@ -5,18 +5,8 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { ActionGroup } from './rule_type';
export type DefaultActionGroupId = 'default';
export type RecoveredActionGroupId = typeof RecoveredActionGroup['id'];
export const RecoveredActionGroup: Readonly<ActionGroup<'recovered'>> = Object.freeze({
id: 'recovered',
name: i18n.translate('xpack.alerting.builtinActionGroups.recovered', {
defaultMessage: 'Recovered',
}),
});
import type { RecoveredActionGroupId, ActionGroup } from '@kbn/alerting-types';
import { RecoveredActionGroup } from '@kbn/alerting-types';
export type ReservedActionGroups<RecoveryActionGroupId extends string> =
| RecoveryActionGroupId
@ -32,3 +22,7 @@ export function getBuiltinActionGroups<RecoveryActionGroupId extends string>(
): [ActionGroup<ReservedActionGroups<RecoveryActionGroupId>>] {
return [customRecoveryGroup ?? RecoveredActionGroup];
}
export type { RecoveredActionGroupId, DefaultActionGroupId } from '@kbn/alerting-types';
export { RecoveredActionGroup } from '@kbn/alerting-types';

View file

@ -38,6 +38,7 @@ export * from './rule_tags_aggregation';
export * from './iso_weekdays';
export * from './saved_objects/rules/mappings';
export * from './rule_circuit_breaker_error_message';
export * from './maintenance_window_scoped_query_error_message';
export type {
MaintenanceWindowModificationMetadata,
@ -48,6 +49,7 @@ export type {
MaintenanceWindowCreateBody,
MaintenanceWindowClientContext,
MaintenanceWindowDeepLinkIds,
ScopedQueryAttributes,
} from './maintenance_window';
export {

View file

@ -0,0 +1,16 @@
/*
* 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.
*/
const errorMessageIdentifier = 'invalid scoped query';
export const getScopedQueryErrorMessage = (errorMessage: string) => {
return `${errorMessageIdentifier} - ${errorMessage}`;
};
export const isScopedQueryError = (errorMessage: string) => {
return errorMessage.includes(errorMessageIdentifier);
};

View file

@ -15,6 +15,8 @@ import { IsoWeekday } from './iso_weekdays';
import { RuleNotifyWhenType } from './rule_notify_when_type';
import { RuleSnooze } from './rule_snooze_type';
export type { ActionVariable } from '@kbn/alerting-types';
export type RuleTypeState = Record<string, unknown>;
export type RuleTypeParams = Record<string, unknown>;
@ -242,14 +244,6 @@ export interface AlertsHealth {
};
}
export interface ActionVariable {
name: string;
description: string;
deprecated?: boolean;
useWithTripleBracesInTemplates?: boolean;
usesPublicBaseUrl?: boolean;
}
export interface RuleMonitoringHistory extends SavedObjectAttributes {
success: boolean;
timestamp: number;

View file

@ -5,50 +5,4 @@
* 2.0.
*/
import { LicenseType } from '@kbn/licensing-plugin/common/types';
import { RecoveredActionGroupId, DefaultActionGroupId } from './builtin_action_groups';
interface ConsumerPrivileges {
read: boolean;
all: boolean;
}
interface ActionVariable {
name: string;
description: string;
}
export interface RuleType<
ActionGroupIds extends Exclude<string, RecoveredActionGroupId> = DefaultActionGroupId,
RecoveryActionGroupId extends string = RecoveredActionGroupId
> {
id: string;
name: string;
actionGroups: Array<ActionGroup<ActionGroupIds>>;
recoveryActionGroup: ActionGroup<RecoveryActionGroupId>;
actionVariables: {
context: ActionVariable[];
state: ActionVariable[];
params: ActionVariable[];
};
defaultActionGroupId: ActionGroupIds;
category: string;
producer: string;
minimumLicenseRequired: LicenseType;
isExportable: boolean;
ruleTaskTimeout?: string;
defaultScheduleInterval?: string;
doesSetRecoveryContext?: boolean;
enabledInLicense: boolean;
authorizedConsumers: Record<string, ConsumerPrivileges>;
}
export interface ActionGroup<ActionGroupIds extends string> {
id: ActionGroupIds;
name: string;
}
export type ActionGroupIdsOf<T> = T extends ActionGroup<infer groups>
? groups
: T extends Readonly<ActionGroup<infer groups>>
? groups
: never;
export type { RuleType, ActionGroup, ActionGroupIdsOf } from '@kbn/alerting-types';

View file

@ -7,12 +7,20 @@
import { i18n } from '@kbn/i18n';
import { useMutation } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { KibanaServerError } from '@kbn/kibana-utils-plugin/public';
import { useKibana } from '../utils/kibana_react';
import { MaintenanceWindow } from '../pages/maintenance_windows/types';
import { createMaintenanceWindow } from '../services/maintenance_windows_api/create';
export function useCreateMaintenanceWindow() {
interface UseCreateMaintenanceWindowProps {
onError?: (error: IHttpFetchError<KibanaServerError>) => void;
}
export function useCreateMaintenanceWindow(props?: UseCreateMaintenanceWindowProps) {
const { onError } = props || {};
const {
http,
notifications: { toasts },
@ -33,12 +41,13 @@ export function useCreateMaintenanceWindow() {
})
);
},
onError: () => {
onError: (error: IHttpFetchError<KibanaServerError>) => {
toasts.addDanger(
i18n.translate('xpack.alerting.maintenanceWindowsCreateFailure', {
defaultMessage: 'Failed to create maintenance window.',
})
);
onError?.(error);
},
});
}

View file

@ -32,6 +32,7 @@ export const useGetRuleTypes = () => {
queryKey: ['useGetRuleTypes'],
queryFn,
onError,
refetchOnWindowFocus: false,
});
return {

View file

@ -7,12 +7,20 @@
import { i18n } from '@kbn/i18n';
import { useMutation } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { KibanaServerError } from '@kbn/kibana-utils-plugin/public';
import { useKibana } from '../utils/kibana_react';
import { MaintenanceWindow } from '../pages/maintenance_windows/types';
import { updateMaintenanceWindow } from '../services/maintenance_windows_api/update';
export function useUpdateMaintenanceWindow() {
interface UseUpdateMaintenanceWindowProps {
onError?: (error: IHttpFetchError<KibanaServerError>) => void;
}
export function useUpdateMaintenanceWindow(props?: UseUpdateMaintenanceWindowProps) {
const { onError } = props || {};
const {
http,
notifications: { toasts },
@ -39,12 +47,13 @@ export function useUpdateMaintenanceWindow() {
})
);
},
onError: () => {
onError: (error: IHttpFetchError<KibanaServerError>) => {
toasts.addDanger(
i18n.translate('xpack.alerting.maintenanceWindowsUpdateFailure', {
defaultMessage: 'Failed to update maintenance window.',
})
);
onError?.(error);
},
});
}

View file

@ -45,6 +45,11 @@ describe('CreateMaintenanceWindowForm', () => {
addDanger: jest.fn(),
},
},
unifiedSearch: {
ui: {
SearchBar: <div />,
},
},
},
});
@ -136,13 +141,13 @@ describe('CreateMaintenanceWindowForm', () => {
const observabilityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-observability');
).getByTestId('option-observability');
const securityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-securitySolution');
).getByTestId('option-securitySolution');
const managementInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-management');
).getByTestId('option-management');
expect(observabilityInput).toBeChecked();
expect(securityInput).toBeChecked();
@ -176,13 +181,13 @@ describe('CreateMaintenanceWindowForm', () => {
const observabilityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-observability');
).getByTestId('option-observability');
const securityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-securitySolution');
).getByTestId('option-securitySolution');
const managementInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-management');
).getByTestId('option-management');
expect(observabilityInput).toBeChecked();
expect(securityInput).toBeChecked();
@ -213,13 +218,13 @@ describe('CreateMaintenanceWindowForm', () => {
const observabilityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-observability');
).getByTestId('option-observability');
const securityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-securitySolution');
).getByTestId('option-securitySolution');
const managementInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-management');
).getByTestId('option-management');
expect(observabilityInput).toBeChecked();
expect(managementInput).toBeChecked();
@ -237,13 +242,13 @@ describe('CreateMaintenanceWindowForm', () => {
const observabilityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-observability');
).getByTestId('option-observability');
const securityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-securitySolution');
).getByTestId('option-securitySolution');
const managementInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-management');
).getByTestId('option-management');
expect(observabilityInput).toBeChecked();
expect(securityInput).toBeChecked();

View file

@ -30,12 +30,16 @@ import {
} from '@elastic/eui';
import { TIMEZONE_OPTIONS as UI_TIMEZONE_OPTIONS } from '@kbn/core-ui-settings-common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common';
import type { ValidFeatureId } from '@kbn/rule-data-utils';
import type { Filter } from '@kbn/es-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { KibanaServerError } from '@kbn/kibana-utils-plugin/public';
import { FormProps, schema } from './schema';
import * as i18n from '../translations';
import { RecurringSchedule } from './recurring_schedule_form/recurring_schedule';
import { SubmitButton } from './submit_button';
import { convertToRRule } from '../helpers/convert_to_rrule';
import { isScopedQueryError } from '../../../../common';
import { useCreateMaintenanceWindow } from '../../../hooks/use_create_maintenance_window';
import { useUpdateMaintenanceWindow } from '../../../hooks/use_update_maintenance_window';
import { useGetRuleTypes } from '../../../hooks/use_get_rule_types';
@ -43,14 +47,23 @@ import { useUiSetting } from '../../../utils/kibana_react';
import { DatePickerRangeField } from './fields/date_picker_range_field';
import { useArchiveMaintenanceWindow } from '../../../hooks/use_archive_maintenance_window';
import { MaintenanceWindowCategorySelection } from './maintenance_window_category_selection';
import { MaintenanceWindowScopedQuerySwitch } from './maintenance_window_scoped_query_switch';
import { MaintenanceWindowScopedQuery } from './maintenance_window_scoped_query';
const UseField = getUseField({ component: Field });
const VALID_CATEGORIES = [
DEFAULT_APP_CATEGORIES.observability.id,
DEFAULT_APP_CATEGORIES.security.id,
DEFAULT_APP_CATEGORIES.management.id,
];
export interface CreateMaintenanceWindowFormProps {
onCancel: () => void;
onSuccess: () => void;
initialValue?: FormProps;
maintenanceWindowId?: string;
scopedQueryFeatureFlag?: boolean;
}
const useDefaultTimezone = () => {
@ -62,317 +75,433 @@ const useDefaultTimezone = () => {
};
const TIMEZONE_OPTIONS = UI_TIMEZONE_OPTIONS.map((n) => ({ label: n })) ?? [{ label: 'UTC' }];
export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFormProps>(
({ onCancel, onSuccess, initialValue, maintenanceWindowId }) => {
const [defaultStartDateValue] = useState<string>(moment().toISOString());
const [defaultEndDateValue] = useState<string>(moment().add(30, 'minutes').toISOString());
const [isModalVisible, setIsModalVisible] = useState(false);
const { defaultTimezone, isBrowser } = useDefaultTimezone();
export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFormProps>((props) => {
const {
onCancel,
onSuccess,
initialValue,
maintenanceWindowId,
scopedQueryFeatureFlag = true,
} = props;
const isEditMode = initialValue !== undefined && maintenanceWindowId !== undefined;
const [defaultStartDateValue] = useState<string>(moment().toISOString());
const [defaultEndDateValue] = useState<string>(moment().add(30, 'minutes').toISOString());
const [isModalVisible, setIsModalVisible] = useState(false);
const { defaultTimezone, isBrowser } = useDefaultTimezone();
const hasSetInitialCategories = useRef<boolean>(false);
const [isScopedQueryEnabled, setIsScopedQueryEnabled] = useState(!!initialValue?.scopedQuery);
const [query, setQuery] = useState<string>(initialValue?.scopedQuery?.kql || '');
const [filters, setFilters] = useState<Filter[]>(
(initialValue?.scopedQuery?.filters as Filter[]) || []
);
const [scopedQueryErrors, setScopedQueryErrors] = useState<string[]>([]);
const hasSetInitialCategories = useRef<boolean>(false);
const categoryIdsHistory = useRef<string[]>([]);
const { mutate: createMaintenanceWindow, isLoading: isCreateLoading } =
useCreateMaintenanceWindow();
const { mutate: updateMaintenanceWindow, isLoading: isUpdateLoading } =
useUpdateMaintenanceWindow();
const { mutate: archiveMaintenanceWindow } = useArchiveMaintenanceWindow();
const isEditMode = initialValue !== undefined && maintenanceWindowId !== undefined;
const { data: ruleTypes, isLoading: isLoadingRuleTypes } = useGetRuleTypes();
const onCreateOrUpdateError = useCallback((error: IHttpFetchError<KibanaServerError>) => {
if (!error.body?.message) {
return;
}
if (isScopedQueryError(error.body.message)) {
setScopedQueryErrors([i18n.CREATE_FORM_SCOPED_QUERY_INVALID_ERROR_MESSAGE]);
}
}, []);
const submitMaintenanceWindow = useCallback(
async (formData, isValid) => {
if (isValid) {
const startDate = moment(formData.startDate);
const endDate = moment(formData.endDate);
const maintenanceWindow = {
title: formData.title,
duration: endDate.diff(startDate),
rRule: convertToRRule(
startDate,
formData.timezone ? formData.timezone[0] : defaultTimezone,
formData.recurringSchedule
),
categoryIds: formData.categoryIds,
};
if (isEditMode) {
updateMaintenanceWindow({ maintenanceWindowId, maintenanceWindow }, { onSuccess });
} else {
createMaintenanceWindow(maintenanceWindow, { onSuccess });
}
}
},
[
isEditMode,
maintenanceWindowId,
updateMaintenanceWindow,
createMaintenanceWindow,
onSuccess,
defaultTimezone,
]
);
const { form } = useForm<FormProps>({
defaultValue: initialValue,
options: { stripEmptyFields: false },
schema,
onSubmit: submitMaintenanceWindow,
const { mutate: createMaintenanceWindow, isLoading: isCreateLoading } =
useCreateMaintenanceWindow({
onError: onCreateOrUpdateError,
});
const [{ recurring, timezone, categoryIds }, _, mounted] = useFormData<FormProps>({
form,
watch: ['recurring', 'timezone', 'categoryIds'],
const { mutate: updateMaintenanceWindow, isLoading: isUpdateLoading } =
useUpdateMaintenanceWindow({
onError: onCreateOrUpdateError,
});
const isRecurring = recurring || false;
const showTimezone = isBrowser || initialValue?.timezone !== undefined;
const closeModal = useCallback(() => setIsModalVisible(false), []);
const showModal = useCallback(() => setIsModalVisible(true), []);
const { mutate: archiveMaintenanceWindow } = useArchiveMaintenanceWindow();
const { setFieldValue } = form;
const { data: ruleTypes, isLoading: isLoadingRuleTypes } = useGetRuleTypes();
const onCategoryIdsChange = useCallback(
(id: string) => {
if (!categoryIds) {
return;
}
if (categoryIds.includes(id)) {
setFieldValue(
'categoryIds',
categoryIds.filter((category) => category !== id)
);
return;
}
setFieldValue('categoryIds', [...categoryIds, id]);
},
[categoryIds, setFieldValue]
);
const scopedQueryPayload = useMemo(() => {
if (!isScopedQueryEnabled || !scopedQueryFeatureFlag) {
return null;
}
if (!query && !filters.length) {
return null;
}
return {
kql: query,
filters,
};
}, [isScopedQueryEnabled, scopedQueryFeatureFlag, query, filters]);
const modal = useMemo(() => {
let m;
if (isModalVisible) {
m = (
<EuiConfirmModal
title={i18n.ARCHIVE_TITLE}
onCancel={closeModal}
onConfirm={() => {
closeModal();
archiveMaintenanceWindow(
{ maintenanceWindowId: maintenanceWindowId!, archive: true },
{ onSuccess }
);
}}
cancelButtonText={i18n.CANCEL}
confirmButtonText={i18n.ARCHIVE_TITLE}
defaultFocusedButton="confirm"
buttonColor="danger"
>
<p>{i18n.ARCHIVE_CALLOUT_SUBTITLE}</p>
</EuiConfirmModal>
);
const submitMaintenanceWindow = useCallback(
async (formData, isValid) => {
if (!isValid || scopedQueryErrors.length !== 0) {
return;
}
return m;
}, [closeModal, archiveMaintenanceWindow, isModalVisible, maintenanceWindowId, onSuccess]);
const availableCategories = useMemo(() => {
if (!ruleTypes) {
return [];
if (isScopedQueryEnabled && !scopedQueryPayload) {
setScopedQueryErrors([i18n.CREATE_FORM_SCOPED_QUERY_EMPTY_ERROR_MESSAGE]);
return;
}
return [...new Set(ruleTypes.map((ruleType) => ruleType.category))];
}, [ruleTypes]);
// For create mode, we want to initialize options to the rule type category the
// user has access
useEffect(() => {
const startDate = moment(formData.startDate);
const endDate = moment(formData.endDate);
const maintenanceWindow = {
title: formData.title,
duration: endDate.diff(startDate),
rRule: convertToRRule(
startDate,
formData.timezone ? formData.timezone[0] : defaultTimezone,
formData.recurringSchedule
),
categoryIds: formData.categoryIds,
scopedQuery: scopedQueryPayload,
};
if (isEditMode) {
return;
updateMaintenanceWindow({ maintenanceWindowId, maintenanceWindow }, { onSuccess });
} else {
createMaintenanceWindow(maintenanceWindow, { onSuccess });
}
if (!mounted) {
return;
}
if (hasSetInitialCategories.current) {
return;
}
if (!ruleTypes) {
return;
}
setFieldValue('categoryIds', [...new Set(ruleTypes.map((ruleType) => ruleType.category))]);
hasSetInitialCategories.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditMode, ruleTypes, mounted]);
},
[
isEditMode,
isScopedQueryEnabled,
scopedQueryErrors,
maintenanceWindowId,
updateMaintenanceWindow,
createMaintenanceWindow,
onSuccess,
defaultTimezone,
scopedQueryPayload,
]
);
// For edit mode, if a maintenance window => category_ids is not an array, this means
// the maintenance window was created before the introduction of category filters.
// For backwards compat we will initialize all options for these.
useEffect(() => {
if (!isEditMode) {
return;
}
if (!mounted) {
return;
}
if (hasSetInitialCategories.current) {
return;
}
if (Array.isArray(categoryIds)) {
return;
}
setFieldValue('categoryIds', [
DEFAULT_APP_CATEGORIES.observability.id,
DEFAULT_APP_CATEGORIES.security.id,
DEFAULT_APP_CATEGORIES.management.id,
]);
hasSetInitialCategories.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditMode, categoryIds, mounted]);
const { form } = useForm<FormProps>({
defaultValue: initialValue,
options: { stripEmptyFields: false },
schema,
onSubmit: submitMaintenanceWindow,
});
return (
<Form form={form}>
<EuiFlexGroup direction="column" responsive={false}>
<EuiFlexItem>
<UseField
path="title"
componentProps={{
'data-test-subj': 'title-field',
euiFieldProps: {
autoFocus: true,
},
}}
/>
</EuiFlexItem>
<EuiSpacer size="xs" />
<EuiFlexItem>
<EuiText size="s">
<h4>{i18n.CREATE_FORM_TIMEFRAME_TITLE}</h4>
<p>
<EuiTextColor color="subdued">
{i18n.CREATE_FORM_TIMEFRAME_DESCRIPTION}
</EuiTextColor>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="flexEnd" responsive={false}>
<EuiFlexItem grow={3}>
<UseMultiFields
fields={{
startDate: {
path: 'startDate',
config: {
label: i18n.CREATE_FORM_SCHEDULE,
defaultValue: defaultStartDateValue,
validations: [],
},
const [{ recurring, timezone, categoryIds }, _, mounted] = useFormData<FormProps>({
form,
watch: ['recurring', 'timezone', 'categoryIds', 'scopedQuery'],
});
const isRecurring = recurring || false;
const showTimezone = isBrowser || initialValue?.timezone !== undefined;
const closeModal = useCallback(() => setIsModalVisible(false), []);
const showModal = useCallback(() => setIsModalVisible(true), []);
const { setFieldValue } = form;
const validRuleTypes = useMemo(() => {
if (!ruleTypes) {
return [];
}
return ruleTypes.filter((ruleType) => VALID_CATEGORIES.includes(ruleType.category));
}, [ruleTypes]);
const availableCategories = useMemo(() => {
return [...new Set(validRuleTypes.map((ruleType) => ruleType.category))];
}, [validRuleTypes]);
const featureIds = useMemo(() => {
if (!Array.isArray(validRuleTypes) || !Array.isArray(categoryIds) || !mounted) {
return [];
}
const featureIdsSet = new Set<ValidFeatureId>();
validRuleTypes.forEach((ruleType) => {
if (categoryIds.includes(ruleType.category)) {
featureIdsSet.add(ruleType.producer as ValidFeatureId);
}
});
return [...featureIdsSet];
}, [validRuleTypes, categoryIds, mounted]);
const onCategoryIdsChange = useCallback(
(ids: string[]) => {
if (!categoryIds) {
return;
}
setFieldValue('categoryIds', ids);
},
[categoryIds, setFieldValue]
);
const onScopeQueryToggle = useCallback(
(isEnabled: boolean) => {
if (isEnabled) {
setFieldValue('categoryIds', [categoryIds?.sort()[0] || availableCategories.sort()[0]]);
} else {
setFieldValue('categoryIds', categoryIdsHistory.current);
}
setIsScopedQueryEnabled(isEnabled);
},
[categoryIds, availableCategories, setFieldValue]
);
const onQueryChange = useCallback(
(newQuery: string) => {
if (scopedQueryErrors.length) {
setScopedQueryErrors([]);
}
setQuery(newQuery);
},
[scopedQueryErrors]
);
const modal = useMemo(() => {
let m;
if (isModalVisible) {
m = (
<EuiConfirmModal
title={i18n.ARCHIVE_TITLE}
onCancel={closeModal}
onConfirm={() => {
closeModal();
archiveMaintenanceWindow(
{ maintenanceWindowId: maintenanceWindowId!, archive: true },
{ onSuccess }
);
}}
cancelButtonText={i18n.CANCEL}
confirmButtonText={i18n.ARCHIVE_TITLE}
defaultFocusedButton="confirm"
buttonColor="danger"
>
<p>{i18n.ARCHIVE_CALLOUT_SUBTITLE}</p>
</EuiConfirmModal>
);
}
return m;
}, [closeModal, archiveMaintenanceWindow, isModalVisible, maintenanceWindowId, onSuccess]);
// For create mode, we want to initialize options to the rule type category the
// user has access
useEffect(() => {
if (isEditMode) {
return;
}
if (!mounted) {
return;
}
if (hasSetInitialCategories.current) {
return;
}
if (!validRuleTypes.length) {
return;
}
setFieldValue('categoryIds', [...new Set(validRuleTypes.map((ruleType) => ruleType.category))]);
hasSetInitialCategories.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditMode, validRuleTypes, mounted]);
// For edit mode, if a maintenance window => category_ids is not an array, this means
// the maintenance window was created before the introduction of category filters.
// For backwards compat we will initialize all options for these.
useEffect(() => {
if (!isEditMode) {
return;
}
if (!mounted) {
return;
}
if (hasSetInitialCategories.current) {
return;
}
if (Array.isArray(categoryIds)) {
return;
}
setFieldValue('categoryIds', VALID_CATEGORIES);
hasSetInitialCategories.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditMode, categoryIds, mounted]);
useEffect(() => {
if (!isScopedQueryEnabled && Array.isArray(categoryIds)) {
categoryIdsHistory.current = categoryIds;
}
}, [categoryIds, isScopedQueryEnabled]);
return (
<Form form={form}>
<EuiFlexGroup direction="column" responsive={false}>
<EuiFlexItem>
<UseField
path="title"
componentProps={{
'data-test-subj': 'title-field',
euiFieldProps: {
autoFocus: true,
},
}}
/>
</EuiFlexItem>
<EuiSpacer size="xs" />
<EuiFlexItem>
<EuiText size="s">
<h4>{i18n.CREATE_FORM_TIMEFRAME_TITLE}</h4>
<p>
<EuiTextColor color="subdued">{i18n.CREATE_FORM_TIMEFRAME_DESCRIPTION}</EuiTextColor>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="flexEnd" responsive={false}>
<EuiFlexItem grow={3}>
<UseMultiFields
fields={{
startDate: {
path: 'startDate',
config: {
label: i18n.CREATE_FORM_SCHEDULE,
defaultValue: defaultStartDateValue,
validations: [],
},
endDate: {
path: 'endDate',
config: {
label: '',
defaultValue: defaultEndDateValue,
validations: [],
},
},
endDate: {
path: 'endDate',
config: {
label: '',
defaultValue: defaultEndDateValue,
validations: [],
},
},
}}
>
{(fields) => (
<DatePickerRangeField
fields={fields}
timezone={timezone ?? [defaultTimezone]}
data-test-subj="date-field"
/>
)}
</UseMultiFields>
</EuiFlexItem>
{showTimezone ? (
<EuiFlexItem grow={1}>
<UseField
path="timezone"
config={{
type: FIELD_TYPES.COMBO_BOX,
validations: [],
defaultValue: [defaultTimezone],
}}
componentProps={{
'data-test-subj': 'timezone-field',
id: 'timezone',
euiFieldProps: {
fullWidth: true,
options: TIMEZONE_OPTIONS,
singleSelection: { asPlainText: true },
isClearable: false,
noSuggestions: false,
placeholder: '',
prepend: (
<EuiFormLabel htmlFor={'timezone'}>
{i18n.CREATE_FORM_TIMEZONE}
</EuiFormLabel>
),
},
}}
>
{(fields) => (
<DatePickerRangeField
fields={fields}
timezone={timezone ?? [defaultTimezone]}
data-test-subj="date-field"
/>
)}
</UseMultiFields>
/>
</EuiFlexItem>
{showTimezone ? (
<EuiFlexItem grow={1}>
<UseField
path="timezone"
config={{
type: FIELD_TYPES.COMBO_BOX,
validations: [],
defaultValue: [defaultTimezone],
}}
componentProps={{
'data-test-subj': 'timezone-field',
id: 'timezone',
euiFieldProps: {
fullWidth: true,
options: TIMEZONE_OPTIONS,
singleSelection: { asPlainText: true },
isClearable: false,
noSuggestions: false,
placeholder: '',
prepend: (
<EuiFormLabel htmlFor={'timezone'}>
{i18n.CREATE_FORM_TIMEZONE}
</EuiFormLabel>
),
},
}}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="recurring"
componentProps={{
'data-test-subj': 'recurring-field',
}}
/>
</EuiFlexItem>
{isRecurring && (
<EuiFlexItem>
<UseField
path="recurring"
componentProps={{
'data-test-subj': 'recurring-field',
}}
/>
<RecurringSchedule data-test-subj="recurring-form" />
</EuiFlexItem>
{isRecurring && (
<EuiFlexItem>
<RecurringSchedule data-test-subj="recurring-form" />
</EuiFlexItem>
)}
)}
{scopedQueryFeatureFlag && (
<EuiFlexItem>
<EuiHorizontalRule margin="xl" />
<UseField path="categoryIds">
{(field) => (
<MaintenanceWindowCategorySelection
selectedCategories={categoryIds || []}
availableCategories={availableCategories}
isLoading={isLoadingRuleTypes}
errors={field.errors.map((error) => error.message)}
onChange={onCategoryIdsChange}
<UseField path="scopedQuery">
{() => (
<MaintenanceWindowScopedQuerySwitch
checked={isScopedQueryEnabled}
onEnabledChange={onScopeQueryToggle}
/>
)}
</UseField>
<EuiHorizontalRule margin="xl" />
</EuiFlexItem>
</EuiFlexGroup>
{isEditMode && (
<>
<EuiCallOut title={i18n.ARCHIVE_TITLE} color="danger" iconType="trash">
<p>{i18n.ARCHIVE_SUBTITLE}</p>
<EuiButton fill color="danger" onClick={showModal}>
{i18n.ARCHIVE}
</EuiButton>
{modal}
</EuiCallOut>
<EuiHorizontalRule margin="xl" />
</>
)}
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="l"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCancel} size="s" data-test-subj="cancelMaintenanceWindow">
{i18n.CANCEL}
</EuiButtonEmpty>
<EuiFlexItem>
<EuiHorizontalRule margin="xl" />
<UseField path="categoryIds">
{(field) => (
<MaintenanceWindowCategorySelection
isScopedQueryEnabled={isScopedQueryEnabled}
isLoading={isLoadingRuleTypes}
selectedCategories={categoryIds || []}
availableCategories={availableCategories}
errors={field.errors.map((error) => error.message)}
onChange={onCategoryIdsChange}
/>
)}
</UseField>
</EuiFlexItem>
{scopedQueryFeatureFlag && (
<EuiFlexItem>
<UseField path="scopedQuery">
{() => (
<MaintenanceWindowScopedQuery
featureIds={featureIds}
query={query}
filters={filters}
isLoading={isLoadingRuleTypes}
isEnabled={isScopedQueryEnabled}
errors={scopedQueryErrors}
onQueryChange={onQueryChange}
onFiltersChange={setFilters}
/>
)}
</UseField>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SubmitButton isLoading={isCreateLoading || isUpdateLoading} editMode={isEditMode} />
</EuiFlexItem>
</EuiFlexGroup>
</Form>
);
}
);
)}
<EuiHorizontalRule margin="xl" />
</EuiFlexGroup>
{isEditMode && (
<>
<EuiCallOut title={i18n.ARCHIVE_TITLE} color="danger" iconType="trash">
<p>{i18n.ARCHIVE_SUBTITLE}</p>
<EuiButton fill color="danger" onClick={showModal}>
{i18n.ARCHIVE}
</EuiButton>
{modal}
</EuiCallOut>
<EuiHorizontalRule margin="xl" />
</>
)}
<EuiFlexGroup alignItems="center" justifyContent="flexEnd" gutterSize="l" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCancel} size="s" data-test-subj="cancelMaintenanceWindow">
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SubmitButton isLoading={isCreateLoading || isUpdateLoading} editMode={isEditMode} />
</EuiFlexItem>
</EuiFlexGroup>
</Form>
);
});
CreateMaintenanceWindowForm.displayName = 'CreateMaintenanceWindowForm';

View file

@ -29,9 +29,9 @@ describe('maintenanceWindowCategorySelection', () => {
/>
);
expect(screen.getByTestId('checkbox-observability')).not.toBeDisabled();
expect(screen.getByTestId('checkbox-securitySolution')).not.toBeDisabled();
expect(screen.getByTestId('checkbox-management')).not.toBeDisabled();
expect(screen.getByTestId('option-observability')).not.toBeDisabled();
expect(screen.getByTestId('option-securitySolution')).not.toBeDisabled();
expect(screen.getByTestId('option-management')).not.toBeDisabled();
});
it('should disable options if option is not in the available categories array', () => {
@ -43,9 +43,9 @@ describe('maintenanceWindowCategorySelection', () => {
/>
);
expect(screen.getByTestId('checkbox-observability')).toBeDisabled();
expect(screen.getByTestId('checkbox-securitySolution')).toBeDisabled();
expect(screen.getByTestId('checkbox-management')).toBeDisabled();
expect(screen.getByTestId('option-observability')).toBeDisabled();
expect(screen.getByTestId('option-securitySolution')).toBeDisabled();
expect(screen.getByTestId('option-management')).toBeDisabled();
});
it('can initialize checkboxes with initial values from props', async () => {
@ -57,9 +57,9 @@ describe('maintenanceWindowCategorySelection', () => {
/>
);
expect(screen.getByTestId('checkbox-observability')).not.toBeChecked();
expect(screen.getByTestId('checkbox-securitySolution')).toBeChecked();
expect(screen.getByTestId('checkbox-management')).toBeChecked();
expect(screen.getByTestId('option-observability')).not.toBeChecked();
expect(screen.getByTestId('option-securitySolution')).toBeChecked();
expect(screen.getByTestId('option-management')).toBeChecked();
});
it('can check checkboxes', async () => {
@ -71,16 +71,16 @@ describe('maintenanceWindowCategorySelection', () => {
/>
);
const managementCheckbox = screen.getByTestId('checkbox-management');
const securityCheckbox = screen.getByTestId('checkbox-securitySolution');
const managementCheckbox = screen.getByTestId('option-management');
const securityCheckbox = screen.getByTestId('option-securitySolution');
fireEvent.click(managementCheckbox);
expect(mockOnChange).toHaveBeenLastCalledWith('management', expect.anything());
expect(mockOnChange).toHaveBeenLastCalledWith(['observability', 'management']);
fireEvent.click(securityCheckbox);
expect(mockOnChange).toHaveBeenLastCalledWith('securitySolution', expect.anything());
expect(mockOnChange).toHaveBeenLastCalledWith(['observability', 'securitySolution']);
});
it('should display loading spinner if isLoading is true', () => {
@ -106,4 +106,62 @@ describe('maintenanceWindowCategorySelection', () => {
);
expect(screen.getByText('test error')).toBeInTheDocument();
});
it('should display radio group if scoped query is enabled', () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
isScopedQueryEnabled={false}
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
expect(
screen.getByTestId('maintenanceWindowCategorySelectionCheckboxGroup')
).toBeInTheDocument();
appMockRenderer.render(
<MaintenanceWindowCategorySelection
isScopedQueryEnabled={true}
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
expect(screen.getByTestId('maintenanceWindowCategorySelectionRadioGroup')).toBeInTheDocument();
});
it('should set only 1 category at a time if scoped query is enabled', () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
isScopedQueryEnabled={true}
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
let managementCheckbox = screen.getByLabelText('Stack rules');
fireEvent.click(managementCheckbox);
expect(mockOnChange).toHaveBeenLastCalledWith(['management']);
appMockRenderer.render(
<MaintenanceWindowCategorySelection
isScopedQueryEnabled={true}
selectedCategories={['observability']}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
managementCheckbox = screen.getByLabelText('Stack rules');
fireEvent.click(managementCheckbox);
expect(mockOnChange).toHaveBeenLastCalledWith(['management']);
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useMemo, useCallback } from 'react';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common';
import {
EuiFlexGroup,
@ -14,36 +14,37 @@ import {
EuiFormRow,
EuiTextColor,
EuiCheckboxGroup,
EuiCheckboxGroupOption,
EuiRadioGroup,
EuiLoadingSpinner,
} from '@elastic/eui';
import * as i18n from '../translations';
const CHECKBOX_OPTIONS: EuiCheckboxGroupOption[] = [
const CHECKBOX_OPTIONS = [
{
id: DEFAULT_APP_CATEGORIES.observability.id,
label: i18n.CREATE_FORM_CATEGORY_OBSERVABILITY_RULES,
['data-test-subj']: `checkbox-${DEFAULT_APP_CATEGORIES.observability.id}`,
['data-test-subj']: `option-${DEFAULT_APP_CATEGORIES.observability.id}`,
},
{
id: DEFAULT_APP_CATEGORIES.security.id,
label: i18n.CREATE_FORM_CATEGORY_SECURITY_RULES,
['data-test-subj']: `checkbox-${DEFAULT_APP_CATEGORIES.security.id}`,
['data-test-subj']: `option-${DEFAULT_APP_CATEGORIES.security.id}`,
},
{
id: DEFAULT_APP_CATEGORIES.management.id,
label: i18n.CREATE_FORM_CATEGORY_STACK_RULES,
['data-test-subj']: `checkbox-${DEFAULT_APP_CATEGORIES.management.id}`,
['data-test-subj']: `option-${DEFAULT_APP_CATEGORIES.management.id}`,
},
];
].sort((a, b) => a.id.localeCompare(b.id));
export interface MaintenanceWindowCategorySelectionProps {
selectedCategories: string[];
availableCategories: string[];
errors?: string[];
isLoading?: boolean;
onChange: (category: string) => void;
isScopedQueryEnabled?: boolean;
onChange: (categories: string[]) => void;
}
export const MaintenanceWindowCategorySelection = (
@ -54,6 +55,7 @@ export const MaintenanceWindowCategorySelection = (
availableCategories,
errors = [],
isLoading = false,
isScopedQueryEnabled = false,
onChange,
} = props;
@ -64,13 +66,59 @@ export const MaintenanceWindowCategorySelection = (
}, {});
}, [selectedCategories]);
const options: EuiCheckboxGroupOption[] = useMemo(() => {
const options = useMemo(() => {
return CHECKBOX_OPTIONS.map((option) => ({
...option,
disabled: !availableCategories.includes(option.id),
}));
})).sort((a, b) => a.id.localeCompare(b.id));
}, [availableCategories]);
const onCheckboxChange = useCallback(
(id: string) => {
if (selectedCategories.includes(id)) {
onChange(selectedCategories.filter((category) => category !== id));
} else {
onChange([...selectedCategories, id]);
}
},
[selectedCategories, onChange]
);
const onRadioChange = useCallback(
(id: string) => {
onChange([id]);
},
[onChange]
);
const categorySelection = useMemo(() => {
if (isScopedQueryEnabled) {
return (
<EuiRadioGroup
data-test-subj="maintenanceWindowCategorySelectionRadioGroup"
options={options}
idSelected={selectedCategories[0]}
onChange={onRadioChange}
/>
);
}
return (
<EuiCheckboxGroup
data-test-subj="maintenanceWindowCategorySelectionCheckboxGroup"
options={options}
idToSelectedMap={selectedMap}
onChange={onCheckboxChange}
/>
);
}, [
isScopedQueryEnabled,
options,
selectedCategories,
selectedMap,
onCheckboxChange,
onRadioChange,
]);
if (isLoading) {
return (
<EuiFlexGroup
@ -102,7 +150,7 @@ export const MaintenanceWindowCategorySelection = (
isInvalid={!!errors.length}
error={errors[0]}
>
<EuiCheckboxGroup options={options} idToSelectedMap={selectedMap} onChange={onChange} />
{categorySelection}
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,87 @@
/*
* 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 React from 'react';
import { screen } from '@testing-library/react';
import type { AlertConsumers } from '@kbn/rule-data-utils';
import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils';
import { MaintenanceWindowScopedQuery } from './maintenance_window_scoped_query';
jest.mock('../../../utils/kibana_react');
jest.mock('@kbn/alerts-ui-shared', () => ({
AlertsSearchBar: () => <div />,
}));
const { useKibana } = jest.requireMock('../../../utils/kibana_react');
describe('MaintenanceWindowScopedQuery', () => {
let appMockRenderer: AppMockRenderer;
beforeEach(() => {
jest.clearAllMocks();
useKibana.mockReturnValue({
services: {
notifications: {
toasts: {
addSuccess: jest.fn(),
addDanger: jest.fn(),
},
},
data: {
dataViews: {},
},
unifiedSearch: {
ui: {
SearchBar: <div />,
},
},
},
});
appMockRenderer = createAppMockRenderer();
});
it('renders correctly', () => {
appMockRenderer.render(
<MaintenanceWindowScopedQuery
featureIds={['observability', 'management', 'securitySolution'] as AlertConsumers[]}
query={''}
filters={[]}
onQueryChange={jest.fn()}
onFiltersChange={jest.fn()}
/>
);
expect(screen.getByTestId('maintenanceWindowScopeQuery')).toBeInTheDocument();
});
it('should hide the search bar if isEnabled is false', () => {
appMockRenderer.render(
<MaintenanceWindowScopedQuery
featureIds={['observability', 'management', 'securitySolution'] as AlertConsumers[]}
isEnabled={false}
query={''}
filters={[]}
onQueryChange={jest.fn()}
onFiltersChange={jest.fn()}
/>
);
expect(screen.queryByTestId('maintenanceWindowScopeQuery')).not.toBeInTheDocument();
});
it('should render loading if isLoading is true', () => {
appMockRenderer.render(
<MaintenanceWindowScopedQuery
featureIds={['observability', 'management', 'securitySolution'] as AlertConsumers[]}
isLoading={true}
query={''}
filters={[]}
onQueryChange={jest.fn()}
onFiltersChange={jest.fn()}
/>
);
expect(screen.getByTestId('maintenanceWindowScopedQueryLoading')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,100 @@
/*
* 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 React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiLoadingSpinner } from '@elastic/eui';
import { AlertConsumers } from '@kbn/rule-data-utils';
import type { Filter } from '@kbn/es-query';
import { AlertsSearchBar } from '@kbn/alerts-ui-shared';
import { PLUGIN } from '../../../../common/constants/plugin';
import { useKibana } from '../../../utils/kibana_react';
export interface MaintenanceWindowScopedQueryProps {
featureIds: AlertConsumers[];
query: string;
filters: Filter[];
errors?: string[];
isLoading?: boolean;
isEnabled?: boolean;
onQueryChange: (query: string) => void;
onFiltersChange: (filters: Filter[]) => void;
}
export const MaintenanceWindowScopedQuery = React.memo(
(props: MaintenanceWindowScopedQueryProps) => {
const {
featureIds,
query,
filters,
errors = [],
isLoading,
isEnabled = true,
onQueryChange,
onFiltersChange,
} = props;
const {
http,
data,
notifications: { toasts },
unifiedSearch: {
ui: { SearchBar },
},
} = useKibana().services;
const onQueryChangeInternal = useCallback(
({ query: newQuery }: { query?: string }) => {
onQueryChange(newQuery || '');
},
[onQueryChange]
);
if (isLoading) {
return (
<EuiFlexGroup
justifyContent="spaceAround"
data-test-subj="maintenanceWindowScopedQueryLoading"
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="l" />
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (!isEnabled) {
return null;
}
return (
<EuiFlexGroup data-test-subj="maintenanceWindowScopeQuery" direction="column">
<EuiFlexItem>
<EuiFormRow fullWidth isInvalid={errors.length !== 0} error={errors[0]}>
<AlertsSearchBar
appName={PLUGIN.getI18nName(i18n)}
featureIds={featureIds}
disableQueryLanguageSwitcher={true}
query={query}
filters={filters}
onQueryChange={onQueryChangeInternal}
onQuerySubmit={onQueryChangeInternal}
onFiltersUpdated={onFiltersChange}
showFilterBar
submitOnBlur
showDatePicker={false}
showSubmitButton={false}
http={http}
toasts={toasts}
unifiedSearchBar={SearchBar}
dataViewsService={data.dataViews}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);

View file

@ -0,0 +1,30 @@
/*
* 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 React from 'react';
import { screen, fireEvent, render } from '@testing-library/react';
import { MaintenanceWindowScopedQuerySwitch } from './maintenance_window_scoped_query_switch';
const mockOnEnabledChange = jest.fn();
describe('MaintenanceWindowScopedQuerySwitch', () => {
it('renders correctly', () => {
render(
<MaintenanceWindowScopedQuerySwitch checked={true} onEnabledChange={mockOnEnabledChange} />
);
expect(screen.getByTestId('maintenanceWindowScopedQuerySwitch')).toBeInTheDocument();
});
it('should call onChange when switch is clicked', () => {
render(
<MaintenanceWindowScopedQuerySwitch checked={true} onEnabledChange={mockOnEnabledChange} />
);
fireEvent.click(screen.getByRole('switch'));
expect(mockOnEnabledChange).toHaveBeenCalledWith(false);
});
});

View file

@ -0,0 +1,56 @@
/*
* 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 React, { useCallback } from 'react';
import {
EuiFlexGroup,
EuiText,
EuiFlexItem,
EuiTextColor,
EuiSwitch,
EuiSwitchEvent,
} from '@elastic/eui';
import * as i18n from '../translations';
interface MaintenanceWindowScopedQuerySwitchProps {
checked: boolean;
onEnabledChange: (checked: boolean) => void;
}
export const MaintenanceWindowScopedQuerySwitch = (
props: MaintenanceWindowScopedQuerySwitchProps
) => {
const { checked, onEnabledChange } = props;
const onEnabledChangeInternal = useCallback(
(event: EuiSwitchEvent) => {
onEnabledChange(event.target.checked);
},
[onEnabledChange]
);
return (
<EuiFlexGroup data-test-subj="maintenanceWindowScopedQuerySwitch" direction="column">
<EuiFlexItem>
<EuiText size="s">
<h4>{i18n.CREATE_FORM_SCOPED_QUERY_TITLE}</h4>
<p>
<EuiTextColor color="subdued">{i18n.CREATE_FORM_SCOPED_QUERY_DESCRIPTION}</EuiTextColor>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiSwitch
label={i18n.CREATE_FORM_SCOPED_QUERY_TOGGLE_TITLE}
checked={checked}
onChange={onEnabledChangeInternal}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -8,10 +8,10 @@
import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { Frequency } from '@kbn/rrule';
import * as i18n from '../translations';
import { EndsOptions, MaintenanceWindowFrequency } from '../constants';
import { ScopedQueryAttributes } from '../../../../common';
const { emptyField } = fieldValidators;
@ -23,6 +23,7 @@ export interface FormProps {
recurring: boolean;
recurringSchedule?: RecurringScheduleFormProps;
categoryIds?: string[];
scopedQuery?: ScopedQueryAttributes | null;
}
export interface RecurringScheduleFormProps {
@ -53,6 +54,12 @@ export const schema: FormSchema<FormProps> = {
},
],
},
scopedQuery: {
defaultValue: {
kql: '',
filters: [],
},
},
startDate: {},
endDate: {},
timezone: {},

View file

@ -123,3 +123,5 @@ export const STATUS_OPTIONS = [
{ value: MaintenanceWindowStatus.Finished, name: i18n.TABLE_STATUS_FINISHED },
{ value: MaintenanceWindowStatus.Archived, name: i18n.TABLE_STATUS_ARCHIVED },
];
export const IS_SCOPED_QUERY_ENABLED = true;

View file

@ -28,6 +28,7 @@ export const convertFromMaintenanceWindowToForm = (
timezone: [maintenanceWindow.rRule.tzid],
recurring,
categoryIds: maintenanceWindow.categoryIds || [],
scopedQuery: maintenanceWindow.scopedQuery,
};
if (!recurring) return form;

View file

@ -14,6 +14,7 @@ import * as i18n from './translations';
import { PageHeader } from './components/page_header';
import { CreateMaintenanceWindowForm } from './components/create_maintenance_windows_form';
import { MAINTENANCE_WINDOW_DEEP_LINK_IDS } from '../../../common';
import { IS_SCOPED_QUERY_ENABLED } from './constants';
export const MaintenanceWindowsCreatePage = React.memo(() => {
useBreadcrumbs(MAINTENANCE_WINDOW_DEEP_LINK_IDS.maintenanceWindowsCreate);
@ -30,6 +31,7 @@ export const MaintenanceWindowsCreatePage = React.memo(() => {
<CreateMaintenanceWindowForm
onCancel={navigateToMaintenanceWindows}
onSuccess={navigateToMaintenanceWindows}
scopedQueryFeatureFlag={IS_SCOPED_QUERY_ENABLED}
/>
</EuiPageSection>
);

View file

@ -17,6 +17,7 @@ import { CreateMaintenanceWindowForm } from './components/create_maintenance_win
import { MAINTENANCE_WINDOW_DEEP_LINK_IDS } from '../../../common';
import { useGetMaintenanceWindow } from '../../hooks/use_get_maintenance_window';
import { CenterJustifiedSpinner } from './components/center_justified_spinner';
import { IS_SCOPED_QUERY_ENABLED } from './constants';
export const MaintenanceWindowsEditPage = React.memo(() => {
const { navigateToMaintenanceWindows } = useMaintenanceWindowsNavigation();
@ -43,6 +44,7 @@ export const MaintenanceWindowsEditPage = React.memo(() => {
maintenanceWindowId={maintenanceWindowId}
onCancel={navigateToMaintenanceWindows}
onSuccess={navigateToMaintenanceWindows}
scopedQueryFeatureFlag={IS_SCOPED_QUERY_ENABLED}
/>
</EuiPageSection>
);

View file

@ -208,6 +208,42 @@ export const CREATE_FORM_CATEGORY_STACK_RULES = i18n.translate(
}
);
export const CREATE_FORM_SCOPED_QUERY_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.scopedQuery.title',
{
defaultMessage: 'Define scope of rules',
}
);
export const CREATE_FORM_SCOPED_QUERY_DESCRIPTION = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.scopedQuery.description',
{
defaultMessage:
'Use KQL to further narrow down the alerts affected by this maintenance window.',
}
);
export const CREATE_FORM_SCOPED_QUERY_TOGGLE_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.scopedQuery.toggleTitle',
{
defaultMessage: 'Filter alerts',
}
);
export const CREATE_FORM_SCOPED_QUERY_INVALID_ERROR_MESSAGE = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.scopedQuery.invalidErrorMessage',
{
defaultMessage: 'Invalid scoped query.',
}
);
export const CREATE_FORM_SCOPED_QUERY_EMPTY_ERROR_MESSAGE = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.scopedQuery.emptyErrorMessage',
{
defaultMessage: 'Scoped query is required.',
}
);
export const CREATE_FORM_FREQUENCY_WEEKLY_ON = (dayOfWeek: string) =>
i18n.translate('xpack.alerting.maintenanceWindows.createForm.frequency.weeklyOnWeekday', {
defaultMessage: 'Weekly on {dayOfWeek}',

View file

@ -20,7 +20,7 @@ export const RRuleFrequencyMap = {
export type MaintenanceWindow = Pick<
MaintenanceWindowServerSide,
'title' | 'duration' | 'rRule' | 'categoryIds'
'title' | 'duration' | 'rRule' | 'categoryIds' | 'scopedQuery'
>;
export type MaintenanceWindowFindResponse = MaintenanceWindowServerSide &

View file

@ -10,6 +10,8 @@ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/cor
import { ManagementAppMountParams, ManagementSetup } from '@kbn/management-plugin/public';
import { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { AlertNavigationRegistry, AlertNavigationHandler } from './alert_navigation_registry';
import { loadRule, loadRuleType } from './services/alert_api';
@ -63,6 +65,8 @@ export interface AlertingPluginSetup {
export interface AlertingPluginStart {
licensing: LicensingPluginStart;
spaces: SpacesPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
data: DataPublicPluginStart;
}
export class AlertingPublicPlugin

View file

@ -13,21 +13,25 @@ import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common';
const rewriteBodyRequest: RewriteResponseCase<MaintenanceWindow> = ({
rRule,
categoryIds,
scopedQuery,
...res
}) => ({
...res,
r_rule: rRule,
category_ids: categoryIds,
scoped_query: scopedQuery,
});
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({
r_rule: rRule,
category_ids: categoryIds,
scoped_query: scopedQuery,
...rest
}) => ({
...rest,
rRule,
categoryIds,
scopedQuery,
});
export async function createMaintenanceWindow({

View file

@ -13,9 +13,11 @@ import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common';
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({
r_rule: rRule,
category_ids: categoryIds,
scoped_query: scopedQuery,
...rest
}) => ({
...rest,
scopedQuery,
categoryIds,
rRule,
});

View file

@ -13,21 +13,25 @@ import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common';
const rewriteBodyRequest: RewriteResponseCase<MaintenanceWindow> = ({
rRule,
categoryIds,
scopedQuery,
...res
}) => ({
...res,
r_rule: rRule,
category_ids: categoryIds,
scoped_query: scopedQuery,
});
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({
r_rule: rRule,
category_ids: categoryIds,
scoped_query: scopedQuery,
...rest
}) => ({
...rest,
rRule,
categoryIds,
scopedQuery,
});
export async function updateMaintenanceWindow({

View file

@ -151,7 +151,7 @@ describe('MaintenanceWindowClient - create', () => {
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule as CreateMaintenanceWindowParams['data']['rRule'],
categoryIds: ['observability', 'securitySolution'],
categoryIds: ['securitySolution'],
scopedQuery: {
kql: "_id: '1234'",
filters: [
@ -189,7 +189,7 @@ describe('MaintenanceWindowClient - create', () => {
rRule: mockMaintenanceWindow.rRule,
enabled: true,
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
categoryIds: ['observability', 'securitySolution'],
categoryIds: ['securitySolution'],
...updatedMetadata,
}),
{
@ -245,12 +245,59 @@ describe('MaintenanceWindowClient - create', () => {
},
});
}).rejects.toThrowErrorMatchingInlineSnapshot(`
"Error validating create maintenance scoped query - Expected \\"(\\", \\"{\\", value, whitespace but end of input found.
"Error validating create maintenance window data - invalid scoped query - Expected \\"(\\", \\"{\\", value, whitespace but end of input found.
invalid:
---------^"
`);
});
it('should throw if trying to create a MW with a scoped query with other than 1 category ID', 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(),
});
await expect(async () => {
await createMaintenanceWindow(mockContext, {
data: {
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule as CreateMaintenanceWindowParams['data']['rRule'],
categoryIds: ['observability', 'securitySolution'],
scopedQuery: {
kql: "_id: '1234'",
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
key: 'kibana.alert.action_group',
field: 'kibana.alert.action_group',
params: {
query: 'test',
},
type: 'phrase',
},
$state: {
store: 'appState',
},
query: {
match_phrase: {
'kibana.alert.action_group': 'test',
},
},
},
],
},
},
});
}).rejects.toThrowErrorMatchingInlineSnapshot(
`"Error validating create maintenance window data - scoped query must be accompanied by 1 category ID"`
);
});
it('should throw if trying to create a maintenance window with invalid category ids', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));

View file

@ -11,6 +11,7 @@ import { SavedObjectsUtils } from '@kbn/core/server';
import { buildEsQuery, Filter } from '@kbn/es-query';
import { generateMaintenanceWindowEvents } from '../../lib/generate_maintenance_window_events';
import type { MaintenanceWindowClientContext } from '../../../../../common';
import { getScopedQueryErrorMessage } from '../../../../../common';
import type { MaintenanceWindow } from '../../types';
import type { CreateMaintenanceWindowParams } from './types';
import {
@ -50,7 +51,19 @@ export async function createMaintenanceWindow(
};
}
} catch (error) {
throw Boom.badRequest(`Error validating create maintenance scoped query - ${error.message}`);
throw Boom.badRequest(
`Error validating create maintenance window data - ${getScopedQueryErrorMessage(
error.message
)}`
);
}
if (scopedQueryWithGeneratedValue) {
if (data.categoryIds?.length !== 1) {
throw Boom.badRequest(
`Error validating create maintenance window data - scoped query must be accompanied by 1 category ID`
);
}
}
const id = SavedObjectsUtils.generateId();

View file

@ -223,6 +223,7 @@ describe('MaintenanceWindowClient - update', () => {
} as MaintenanceWindow['rRule'],
events: modifiedEvents,
expirationDate: moment(new Date(firstTimestamp)).tz('UTC').add(2, 'week').toISOString(),
categoryIds: ['observability'],
});
savedObjectsClient.get.mockResolvedValue({
@ -367,12 +368,62 @@ describe('MaintenanceWindowClient - update', () => {
},
});
}).rejects.toThrowErrorMatchingInlineSnapshot(`
"Error validating update maintenance scoped query - Expected \\"(\\", \\"{\\", value, whitespace but end of input found.
"Error validating update maintenance window data - invalid scoped query - Expected \\"(\\", \\"{\\", value, whitespace but end of input found.
invalid:
---------^"
`);
});
it('should throw if trying to update a MW with a scoped query with other than 1 category ID', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date(firstTimestamp)).tz('UTC').subtract(1, 'year').toISOString(),
});
savedObjectsClient.get.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
categoryIds: ['observability', 'securitySolution'],
} as unknown as SavedObject);
await expect(async () => {
await updateMaintenanceWindow(mockContext, {
id: 'test-id',
data: {
scopedQuery: {
kql: "_id: '1234'",
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
key: 'kibana.alert.action_group',
field: 'kibana.alert.action_group',
params: {
query: 'test',
},
type: 'phrase',
},
$state: {
store: 'appState',
},
query: {
match_phrase: {
'kibana.alert.action_group': 'test',
},
},
},
],
},
},
});
}).rejects.toThrowErrorMatchingInlineSnapshot(
`"Failed to update maintenance window by id: test-id, Error: Error: Cannot edit archived maintenance windows: Cannot edit archived maintenance windows"`
);
});
it('should throw if updating a maintenance window that has expired', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const mockMaintenanceWindow = getMockMaintenanceWindow({

View file

@ -9,6 +9,7 @@ import moment from 'moment';
import Boom from '@hapi/boom';
import { buildEsQuery, Filter } from '@kbn/es-query';
import type { MaintenanceWindowClientContext } from '../../../../../common';
import { getScopedQueryErrorMessage } from '../../../../../common';
import type { MaintenanceWindow } from '../../types';
import {
generateMaintenanceWindowEvents,
@ -70,7 +71,11 @@ async function updateWithOCC(
};
}
} catch (error) {
throw Boom.badRequest(`Error validating update maintenance scoped query - ${error.message}`);
throw Boom.badRequest(
`Error validating update maintenance window data - ${getScopedQueryErrorMessage(
error.message
)}`
);
}
try {
@ -119,6 +124,14 @@ async function updateWithOCC(
updatedAt: modificationMetadata.updatedAt,
});
if (updateMaintenanceWindowAttributes.scopedQuery) {
if (updateMaintenanceWindowAttributes.categoryIds?.length !== 1) {
throw Boom.badRequest(
`Error validating update maintenance window data - scoped query must be accompanied by 1 category ID`
);
}
}
// We are deleting and then creating rather than updating because SO.update
// performs a partial update on the rRule, we would need to null out all of the fields
// that are removed from a new rRule if that were the case.

View file

@ -40,6 +40,7 @@
"@kbn/share-plugin",
"@kbn/safer-lodash-set",
"@kbn/alerting-state-types",
"@kbn/alerting-types",
"@kbn/alerts-as-data-utils",
"@kbn/core-elasticsearch-client-server-mocks",
"@kbn/rrule",
@ -60,7 +61,9 @@
"@kbn/core-http-router-server-mocks",
"@kbn/core-elasticsearch-server",
"@kbn/core-application-common",
"@kbn/core-saved-objects-api-server"
"@kbn/core-saved-objects-api-server",
"@kbn/alerts-ui-shared",
"@kbn/core-http-browser"
],
"exclude": ["target/**/*"]
}

View file

@ -8027,7 +8027,6 @@
"xpack.alerting.breadcrumbs.editMaintenanceWindowsLinkText": "Modifier",
"xpack.alerting.breadcrumbs.maintenanceWindowsLinkText": "Fenêtres de maintenance",
"xpack.alerting.breadcrumbs.stackManagementLinkText": "Gestion de la Suite",
"xpack.alerting.builtinActionGroups.recovered": "Récupéré",
"xpack.alerting.feature.flappingSettingsSubFeatureName": "Détection de bagotement",
"xpack.alerting.feature.maintenanceWindowFeatureName": "Fenêtres de maintenance",
"xpack.alerting.feature.rulesSettingsFeatureName": "Paramètres des règles",
@ -41499,7 +41498,6 @@
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage": "Impossible de charger les règles",
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleStatusInfoMessage": "Impossible de charger les infos de statut de règles",
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags": "Impossible de charger les balises de règle",
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTypesMessage": "Impossible de charger les types de règles",
"xpack.triggersActionsUI.sections.rulesList.unableToRunRuleSoon": "Impossible de programmer l'exécution de votre règle",
"xpack.triggersActionsUI.sections.rulesList.weeksLabel": "semaines",
"xpack.triggersActionsUI.sections.ruleTagFilter.loading": "Chargement des balises",

View file

@ -8042,7 +8042,6 @@
"xpack.alerting.breadcrumbs.editMaintenanceWindowsLinkText": "編集",
"xpack.alerting.breadcrumbs.maintenanceWindowsLinkText": "保守時間枠",
"xpack.alerting.breadcrumbs.stackManagementLinkText": "スタック管理",
"xpack.alerting.builtinActionGroups.recovered": "回復済み",
"xpack.alerting.feature.flappingSettingsSubFeatureName": "フラップ検出",
"xpack.alerting.feature.maintenanceWindowFeatureName": "保守時間枠",
"xpack.alerting.feature.rulesSettingsFeatureName": "ルール設定",
@ -41490,7 +41489,6 @@
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage": "ルールを読み込めません",
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleStatusInfoMessage": "ルールステータス情報を読み込めません",
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags": "ルールタグを読み込めません",
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTypesMessage": "ルールタイプを読み込めません",
"xpack.triggersActionsUI.sections.rulesList.unableToRunRuleSoon": "ルールの実行をスケジュールできません",
"xpack.triggersActionsUI.sections.rulesList.weeksLabel": "週",
"xpack.triggersActionsUI.sections.ruleTagFilter.loading": "タグを読み込み中",

View file

@ -8041,7 +8041,6 @@
"xpack.alerting.breadcrumbs.editMaintenanceWindowsLinkText": "编辑",
"xpack.alerting.breadcrumbs.maintenanceWindowsLinkText": "维护窗口",
"xpack.alerting.breadcrumbs.stackManagementLinkText": "Stack Management",
"xpack.alerting.builtinActionGroups.recovered": "已恢复",
"xpack.alerting.feature.flappingSettingsSubFeatureName": "摆动检测",
"xpack.alerting.feature.maintenanceWindowFeatureName": "维护窗口",
"xpack.alerting.feature.rulesSettingsFeatureName": "规则设置",
@ -41483,7 +41482,6 @@
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage": "无法加载规则",
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleStatusInfoMessage": "无法加载规则状态信息",
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags": "无法加载规则标签",
"xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTypesMessage": "无法加载规则类型",
"xpack.triggersActionsUI.sections.rulesList.unableToRunRuleSoon": "无法计划您的要运行的规则",
"xpack.triggersActionsUI.sections.rulesList.weeksLabel": "周",
"xpack.triggersActionsUI.sections.ruleTagFilter.loading": "正在加载标签",

View file

@ -21,6 +21,7 @@ import { useLoadRuleTypesQuery } from '../../hooks/use_load_rule_types_query';
const SA_ALERTS = { type: 'alerts', fields: {} } as SuggestionsAbstraction;
// TODO Share buildEsQuery to be used between AlertsSearchBar and AlertsStateTable component https://github.com/elastic/kibana/issues/144615
// Also TODO: Replace all references to this component with the one from alerts-ui-shared
export function AlertsSearchBar({
appName,
disableQueryLanguageSwitcher = false,

View file

@ -14,6 +14,7 @@ export type {
RuleAction,
Rule,
RuleType,
RuleTypeIndex,
RuleTypeModel,
RuleStatusFilterProps,
RuleStatus,

View file

@ -52,7 +52,6 @@ import {
RuleNotifyWhenType,
RuleTypeParams,
ActionVariable,
RuleType as CommonRuleType,
RuleLastRun,
MaintenanceWindow,
} from '@kbn/alerting-plugin/common';
@ -65,6 +64,7 @@ import {
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import React from 'react';
import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public';
import type { RuleType, RuleTypeIndex } from '@kbn/triggers-actions-ui-types';
import { TypeRegistry } from './application/type_registry';
import type { ComponentOpts as RuleStatusDropdownProps } from './application/sections/rules_list/components/rule_status_dropdown';
import type { RuleTagFilterProps } from './application/sections/rules_list/components/rule_tag_filter';
@ -95,6 +95,14 @@ import type { RulesListNotifyBadgePropsWithApi } from './application/sections/ru
import { Case } from './application/sections/alerts_table/hooks/apis/bulk_get_cases';
import { AlertTableConfigRegistry } from './application/alert_table_config_registry';
export type { ActionVariables, RuleType, RuleTypeIndex } from '@kbn/triggers-actions-ui-types';
export {
REQUIRED_ACTION_VARIABLES,
CONTEXT_ACTION_VARIABLES,
OPTIONAL_ACTION_VARIABLES,
} from '@kbn/triggers-actions-ui-types';
// In Triggers and Actions we treat all `Alert`s as `SanitizedRule<RuleTypeParams>`
// so the `Params` is a black-box of Record<string, unknown>
type SanitizedRule<Params extends RuleTypeParams = never> = Omit<
@ -151,7 +159,6 @@ export {
};
export type ActionTypeIndex = Record<string, ActionType>;
export type RuleTypeIndex = Map<string, RuleType>;
export type ActionTypeRegistryContract<
ActionConnector = unknown,
ActionParams = unknown
@ -329,38 +336,6 @@ export type ActionConnectorTableItem = ActionConnector & {
compatibility: string[];
};
type AsActionVariables<Keys extends string> = {
[Req in Keys]: ActionVariable[];
};
export const REQUIRED_ACTION_VARIABLES = ['params'] as const;
export const CONTEXT_ACTION_VARIABLES = ['context'] as const;
export const OPTIONAL_ACTION_VARIABLES = [...CONTEXT_ACTION_VARIABLES, 'state'] as const;
export type ActionVariables = AsActionVariables<typeof REQUIRED_ACTION_VARIABLES[number]> &
Partial<AsActionVariables<typeof OPTIONAL_ACTION_VARIABLES[number]>>;
export interface RuleType<
ActionGroupIds extends string = string,
RecoveryActionGroupId extends string = string
> extends Pick<
CommonRuleType<ActionGroupIds, RecoveryActionGroupId>,
| 'id'
| 'name'
| 'actionGroups'
| 'producer'
| 'minimumLicenseRequired'
| 'recoveryActionGroup'
| 'defaultActionGroupId'
| 'ruleTaskTimeout'
| 'defaultScheduleInterval'
| 'doesSetRecoveryContext'
> {
actionVariables: ActionVariables;
authorizedConsumers: Record<string, { read: boolean; all: boolean }>;
enabledInLicense: boolean;
hasFieldsForAAD?: boolean;
hasAlertsMappings?: boolean;
}
export type SanitizedRuleType = Omit<RuleType, 'apiKey'>;
export type RuleUpdates = Omit<Rule, 'id' | 'executionStatus' | 'lastRun' | 'nextRun'>;

View file

@ -56,7 +56,8 @@
"@kbn/licensing-plugin",
"@kbn/expressions-plugin",
"@kbn/core-saved-objects-api-server",
"@kbn/serverless"
"@kbn/serverless",
"@kbn/triggers-actions-ui-types"
],
"exclude": ["target/**/*"]
}

View file

@ -10,6 +10,33 @@ import { UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
const scopedQuery = {
kql: "_id: '1234'",
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
key: 'kibana.alert.action_group',
field: 'kibana.alert.action_group',
params: {
query: 'test',
},
type: 'phrase',
},
$state: {
store: 'appState',
},
query: {
match_phrase: {
'kibana.alert.action_group': 'test',
},
},
},
],
};
// eslint-disable-next-line import/no-default-export
export default function createMaintenanceWindowTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@ -25,32 +52,7 @@ export default function createMaintenanceWindowTests({ getService }: FtrProvider
tzid: 'UTC',
freq: 2, // weekly
},
scoped_query: {
kql: "_id: '1234'",
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
key: 'kibana.alert.action_group',
field: 'kibana.alert.action_group',
params: {
query: 'test',
},
type: 'phrase',
},
$state: {
store: 'appState',
},
query: {
match_phrase: {
'kibana.alert.action_group': 'test',
},
},
},
],
},
category_ids: ['management'],
};
afterEach(() => objectRemover.removeAll());
@ -62,7 +64,10 @@ export default function createMaintenanceWindowTests({ getService }: FtrProvider
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(createParams);
.send({
...createParams,
scoped_query: scopedQuery,
});
if (response.body.id) {
objectRemover.add(

View file

@ -11,38 +11,38 @@ import { UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
const scopedQuery = {
kql: "_id: '1234'",
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
key: 'kibana.alert.action_group',
field: 'kibana.alert.action_group',
params: {
query: 'test',
},
type: 'phrase',
},
$state: {
store: 'appState',
},
query: {
match_phrase: {
'kibana.alert.action_group': 'test',
},
},
},
],
};
// eslint-disable-next-line import/no-default-export
export default function updateMaintenanceWindowTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const scopedQuery = {
kql: "_id: '1234'",
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
key: 'kibana.alert.action_group',
field: 'kibana.alert.action_group',
params: {
query: 'test',
},
type: 'phrase',
},
$state: {
store: 'appState',
},
query: {
match_phrase: {
'kibana.alert.action_group': 'test',
},
},
},
],
};
describe('updateMaintenanceWindow', () => {
const objectRemover = new ObjectRemover(supertest);
const createParams = {
@ -53,6 +53,7 @@ export default function updateMaintenanceWindowTests({ getService }: FtrProvider
tzid: 'UTC',
freq: 2, // weekly
},
category_ids: ['management'],
scoped_query: scopedQuery,
};
afterEach(() => objectRemover.removeAll());
@ -276,6 +277,7 @@ export default function updateMaintenanceWindowTests({ getService }: FtrProvider
freq: 2, // weekly
count: 1,
},
category_ids: ['management'],
scoped_query: scopedQuery,
})
.expect(200);

View file

@ -2959,6 +2959,10 @@
version "0.0.0"
uid ""
"@kbn/actions-types@link:packages/kbn-actions-types":
version "0.0.0"
uid ""
"@kbn/advanced-settings-plugin@link:src/plugins/advanced_settings":
version "0.0.0"
uid ""
@ -2999,6 +3003,10 @@
version "0.0.0"
uid ""
"@kbn/alerting-types@link:packages/kbn-alerting-types":
version "0.0.0"
uid ""
"@kbn/alerts-as-data-utils@link:packages/kbn-alerts-as-data-utils":
version "0.0.0"
uid ""
@ -6087,6 +6095,10 @@
version "0.0.0"
uid ""
"@kbn/triggers-actions-ui-types@link:packages/kbn-triggers-actions-ui-types":
version "0.0.0"
uid ""
"@kbn/ts-projects@link:packages/kbn-ts-projects":
version "0.0.0"
uid ""