[8.x] [ResponseOps][Rules] Create Rules APIs package (#214187) (#216006)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[ResponseOps][Rules] Create Rules APIs package
(#214187)](https://github.com/elastic/kibana/pull/214187)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Umberto
Pepato","email":"umbopepato@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-03-26T09:01:51Z","message":"[ResponseOps][Rules]
Create Rules APIs package (#214187)\n\n## Summary\n\n- Creates a
`@kbn/response-ops-rules-apis` package, following the\nproposed
structure for ResponseOps Management Experiences package.\n- Moves
relevant rules API fetchers and react-query hooks to the
new\npackage.\n- Adds an internal variant of the
`/api/alerting/rule_types` endpoint\n(`/internal/alerting/_rule_types`),
that returns the same value as the\npublic one + the newly added
internal
[`solution`\nfield](https://github.com/elastic/kibana/issues/212017),
that we don't\nwant to expose publicly.\n\n## Verification steps\n\n1.
Create rules that fire alerts\n2. Verify the usages of the moved/changed
hooks, with limited privileges\nas well (i.e. only `Rules Settings` but
not `Stack Rules`):\n2.1. Stack management and Observability rules, rule
details and alerts\npages\n2.2. Rules tab in the Connector editor
flyout\n2.3. Alerts table row actions (••• icon)\n2.4. Tags filter in
the rules list page\n3. Using the DevTools, compare the response of the
public and internal\n`rule_types` endpoins:\n ```\n GET
kbn:/api/alerting/rule_types\n GET kbn:/internal/alerting/_rule_types\n
```\nChecking that the `solution` field is present only in the internal
one\n\n## References \n\nCloses #213059 \n\n### Checklist\n\n- [x] [Unit
or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"7aac590af4e245049f3865ea23ed88a179a80a28","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:ResponseOps","Team:obs-ux-management","backport:version","v9.1.0","v8.19.0"],"title":"[ResponseOps][Rules]
Create Rules APIs
package","number":214187,"url":"https://github.com/elastic/kibana/pull/214187","mergeCommit":{"message":"[ResponseOps][Rules]
Create Rules APIs package (#214187)\n\n## Summary\n\n- Creates a
`@kbn/response-ops-rules-apis` package, following the\nproposed
structure for ResponseOps Management Experiences package.\n- Moves
relevant rules API fetchers and react-query hooks to the
new\npackage.\n- Adds an internal variant of the
`/api/alerting/rule_types` endpoint\n(`/internal/alerting/_rule_types`),
that returns the same value as the\npublic one + the newly added
internal
[`solution`\nfield](https://github.com/elastic/kibana/issues/212017),
that we don't\nwant to expose publicly.\n\n## Verification steps\n\n1.
Create rules that fire alerts\n2. Verify the usages of the moved/changed
hooks, with limited privileges\nas well (i.e. only `Rules Settings` but
not `Stack Rules`):\n2.1. Stack management and Observability rules, rule
details and alerts\npages\n2.2. Rules tab in the Connector editor
flyout\n2.3. Alerts table row actions (••• icon)\n2.4. Tags filter in
the rules list page\n3. Using the DevTools, compare the response of the
public and internal\n`rule_types` endpoins:\n ```\n GET
kbn:/api/alerting/rule_types\n GET kbn:/internal/alerting/_rule_types\n
```\nChecking that the `solution` field is present only in the internal
one\n\n## References \n\nCloses #213059 \n\n### Checklist\n\n- [x] [Unit
or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"7aac590af4e245049f3865ea23ed88a179a80a28"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/214187","number":214187,"mergeCommit":{"message":"[ResponseOps][Rules]
Create Rules APIs package (#214187)\n\n## Summary\n\n- Creates a
`@kbn/response-ops-rules-apis` package, following the\nproposed
structure for ResponseOps Management Experiences package.\n- Moves
relevant rules API fetchers and react-query hooks to the
new\npackage.\n- Adds an internal variant of the
`/api/alerting/rule_types` endpoint\n(`/internal/alerting/_rule_types`),
that returns the same value as the\npublic one + the newly added
internal
[`solution`\nfield](https://github.com/elastic/kibana/issues/212017),
that we don't\nwant to expose publicly.\n\n## Verification steps\n\n1.
Create rules that fire alerts\n2. Verify the usages of the moved/changed
hooks, with limited privileges\nas well (i.e. only `Rules Settings` but
not `Stack Rules`):\n2.1. Stack management and Observability rules, rule
details and alerts\npages\n2.2. Rules tab in the Connector editor
flyout\n2.3. Alerts table row actions (••• icon)\n2.4. Tags filter in
the rules list page\n3. Using the DevTools, compare the response of the
public and internal\n`rule_types` endpoins:\n ```\n GET
kbn:/api/alerting/rule_types\n GET kbn:/internal/alerting/_rule_types\n
```\nChecking that the `solution` field is present only in the internal
one\n\n## References \n\nCloses #213059 \n\n### Checklist\n\n- [x] [Unit
or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"7aac590af4e245049f3865ea23ed88a179a80a28"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Alex Szabo <alex.szabo@elastic.co>
This commit is contained in:
Umberto Pepato 2025-03-26 19:24:10 +01:00 committed by GitHub
parent cd9ef106d5
commit 3fddba179f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
136 changed files with 2559 additions and 975 deletions

1
.github/CODEOWNERS vendored
View file

@ -765,6 +765,7 @@ src/platform/packages/shared/response-ops/alerts-fields-browser @elastic/respons
src/platform/packages/shared/response-ops/alerts-table @elastic/response-ops
src/platform/packages/shared/response-ops/rule_form @elastic/response-ops
src/platform/packages/shared/response-ops/rule_params @elastic/response-ops
src/platform/packages/shared/response-ops/rules-apis @elastic/response-ops
examples/response_stream @elastic/ml-ui
src/platform/packages/shared/kbn-rison @elastic/kibana-operations
x-pack/platform/packages/private/rollup @elastic/kibana-management

View file

@ -777,6 +777,7 @@
"@kbn/response-ops-alerts-table": "link:src/platform/packages/shared/response-ops/alerts-table",
"@kbn/response-ops-rule-form": "link:src/platform/packages/shared/response-ops/rule_form",
"@kbn/response-ops-rule-params": "link:src/platform/packages/shared/response-ops/rule_params",
"@kbn/response-ops-rules-apis": "link:src/platform/packages/shared/response-ops/rules-apis",
"@kbn/response-stream-plugin": "link:examples/response_stream",
"@kbn/rison": "link:src/platform/packages/shared/kbn-rison",
"@kbn/rollup": "link:x-pack/platform/packages/private/rollup",

View file

@ -15,6 +15,7 @@ import type {
import type { Filter } from '@kbn/es-query';
import type { RuleNotifyWhenType, RRuleParams } from '.';
export type RuleTypeSolution = 'observability' | 'security' | 'stack';
export type RuleTypeParams = Record<string, unknown>;
export type RuleActionParams = SavedObjectAttributes;
export type RuleActionParam = SavedObjectAttribute;

View file

@ -290,5 +290,6 @@ function getAlertType(actionVariables: ActionVariables): RuleType {
minimumLicenseRequired: 'basic',
enabledInLicense: true,
category: 'my-category',
isExportable: true,
};
}

View file

@ -11,5 +11,5 @@ export * from './use_alerts_data_view';
export * from './use_get_alerts_group_aggregations_query';
export * from './use_health_check';
export * from './use_load_alerting_framework_health';
export * from './use_load_rule_types_query';
export * from './use_get_rule_types_permissions';
export * from './use_load_ui_health';

View file

@ -0,0 +1,168 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { PropsWithChildren } from 'react';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { notificationServiceMock } from '@kbn/core/public/mocks';
import { renderHook, waitFor } from '@testing-library/react';
import { useGetRuleTypesPermissions } from './use_get_rule_types_permissions';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { testQueryClientConfig } from '../test_utils/test_query_client_config';
const http = httpServiceMock.createStartContract();
const { toasts } = notificationServiceMock.createStartContract();
jest.mock('@kbn/response-ops-rules-apis/apis/get_rule_types');
const { getRuleTypes } = jest.requireMock('@kbn/response-ops-rules-apis/apis/get_rule_types');
getRuleTypes.mockResolvedValue([
{
id: 'rule-type-1',
authorizedConsumers: {},
},
{
id: 'rule-type-2',
authorizedConsumers: {},
},
]);
const queryClient = new QueryClient(testQueryClientConfig);
const Wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
describe('useGetRuleTypesPermissions', () => {
afterEach(() => {
queryClient.clear();
});
it('should not filter the rule types if `filteredRuleTypes` and `registeredRuleTypes` are not defined', async () => {
const { result } = renderHook(
() =>
useGetRuleTypesPermissions({
http,
toasts,
enabled: true,
}),
{
wrapper: Wrapper,
}
);
await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(result.current.ruleTypesState.data.size).toBe(2);
expect(result.current.authorizedRuleTypes.length).toBe(2);
});
it('should filter the rule types according to `filteredRuleTypes`', async () => {
const { result } = renderHook(
() =>
useGetRuleTypesPermissions({
http,
toasts,
enabled: true,
filteredRuleTypes: ['rule-type-1'],
}),
{
wrapper: Wrapper,
}
);
await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(result.current.ruleTypesState.data.size).toBe(1);
expect(result.current.authorizedRuleTypes.length).toBe(1);
expect(result.current.ruleTypesState.data.keys().next().value).toBe('rule-type-1');
});
it('should filter out rule types not present in `registeredRuleTypes`', async () => {
const { result } = renderHook(
() =>
useGetRuleTypesPermissions({
http,
toasts,
enabled: true,
registeredRuleTypes: [{ id: 'rule-type-1', description: '' }],
}),
{
wrapper: Wrapper,
}
);
await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(result.current.ruleTypesState.data.size).toBe(1);
expect(result.current.authorizedRuleTypes.length).toBe(1);
expect(result.current.ruleTypesState.data.keys().next().value).toBe('rule-type-1');
});
it('should return the correct authz flags when no rule types are accessible', async () => {
getRuleTypes.mockResolvedValueOnce([]);
const { result } = renderHook(
() =>
useGetRuleTypesPermissions({
http,
toasts,
enabled: true,
}),
{
wrapper: Wrapper,
}
);
await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(result.current.ruleTypesState.data.size).toBe(0);
expect(result.current.hasAnyAuthorizedRuleType).toBe(false);
expect(result.current.authorizedToReadAnyRules).toBe(false);
expect(result.current.authorizedToCreateAnyRules).toBe(false);
});
it('should return the correct authz flags for read-only rule types', async () => {
getRuleTypes.mockResolvedValueOnce([
{
id: 'rule-type-1',
authorizedConsumers: { alerts: { read: true, all: false } },
},
]);
const { result } = renderHook(
() =>
useGetRuleTypesPermissions({
http,
toasts,
enabled: true,
}),
{
wrapper: Wrapper,
}
);
await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(result.current.ruleTypesState.data.size).toBe(1);
expect(result.current.hasAnyAuthorizedRuleType).toBe(true);
expect(result.current.authorizedToReadAnyRules).toBe(true);
expect(result.current.authorizedToCreateAnyRules).toBe(false);
});
it('should return the correct authz flags for read+write rule types', async () => {
getRuleTypes.mockResolvedValueOnce([
{
id: 'rule-type-1',
authorizedConsumers: { alerts: { read: true, all: true } },
},
]);
const { result } = renderHook(
() =>
useGetRuleTypesPermissions({
http,
toasts,
enabled: true,
}),
{
wrapper: Wrapper,
}
);
await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(result.current.ruleTypesState.data.size).toBe(1);
expect(result.current.hasAnyAuthorizedRuleType).toBe(true);
expect(result.current.authorizedToReadAnyRules).toBe(true);
expect(result.current.authorizedToCreateAnyRules).toBe(true);
});
});

View file

@ -9,19 +9,19 @@
import { useMemo } from 'react';
import { keyBy } from 'lodash';
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { UseQueryOptions } from '@tanstack/react-query';
import type { HttpStart } from '@kbn/core-http-browser';
import type { ToastsStart } from '@kbn/core-notifications-browser';
import { i18n } from '@kbn/i18n';
import type { RuleType } from '@kbn/triggers-actions-ui-types';
import {
RuleTypeIndexWithDescriptions,
RuleTypeWithDescription,
} from '@kbn/triggers-actions-ui-types';
import { fetchRuleTypes } from '../apis/fetch_rule_types';
import { useGetRuleTypesQuery } from '@kbn/response-ops-rules-apis/hooks/use_get_rule_types_query';
import { i18n } from '@kbn/i18n';
import { ALERTS_FEATURE_ID } from '../constants';
export interface UseRuleTypesProps {
export interface UseGetRuleTypesPermissionsParams {
http: HttpStart;
toasts: ToastsStart;
filteredRuleTypes?: string[];
@ -37,7 +37,7 @@ const getFilteredIndex = ({
}: {
data: Array<RuleType<string, string>>;
filteredRuleTypes?: string[];
registeredRuleTypes: UseRuleTypesProps['registeredRuleTypes'];
registeredRuleTypes: UseGetRuleTypesPermissionsParams['registeredRuleTypes'];
}) => {
const index: RuleTypeIndexWithDescriptions = new Map();
const registeredRuleTypesDictionary = registeredRuleTypes ? keyBy(registeredRuleTypes, 'id') : {};
@ -63,19 +63,15 @@ const getFilteredIndex = ({
return filteredIndex;
};
export const useLoadRuleTypesQuery = ({
export const useGetRuleTypesPermissions = ({
http,
toasts,
filteredRuleTypes,
registeredRuleTypes,
context,
enabled = true,
}: UseRuleTypesProps) => {
const queryFn = () => {
return fetchRuleTypes({ http });
};
const onErrorFn = (error: Error) => {
}: UseGetRuleTypesPermissionsParams) => {
const onErrorFn = (error: unknown) => {
if (error) {
toasts.addDanger(
i18n.translate('alertsUIShared.hooks.useLoadRuleTypesQuery.unableToLoadRuleTypesMessage', {
@ -84,17 +80,15 @@ export const useLoadRuleTypesQuery = ({
);
}
};
const { data, isSuccess, isFetching, isInitialLoading, isLoading, error } = useQuery({
queryKey: ['loadRuleTypes'],
queryFn,
onError: onErrorFn,
refetchOnWindowFocus: false,
// Leveraging TanStack Query's caching system to avoid duplicated requests as
// other state-sharing solutions turned out to be overly complex and less readable
staleTime: 60 * 1000,
enabled,
context,
});
const { data, isSuccess, isFetching, isInitialLoading, isLoading, error } = useGetRuleTypesQuery(
{ http },
{
onError: onErrorFn,
enabled,
context,
}
);
const filteredIndex = useMemo(
() =>

View file

@ -35,5 +35,6 @@
"@kbn/core-notifications-browser-mocks",
"@kbn/shared-ux-table-persist",
"@kbn/presentation-publishing",
"@kbn/response-ops-rules-apis",
]
}

View file

@ -26,6 +26,7 @@ export interface RuleType<
| 'defaultScheduleInterval'
| 'doesSetRecoveryContext'
| 'category'
| 'isExportable'
> {
actionVariables: ActionVariables;
authorizedConsumers: Record<string, { read: boolean; all: boolean }>;

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { getMutedAlertsInstancesByRule } from './get_muted_alerts_instances_by_rule';
const http = httpServiceMock.createStartContract();

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { muteAlertInstance } from './mute_alert_instance';
const http = httpServiceMock.createStartContract();

View file

@ -7,13 +7,13 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { HttpSetup } from '@kbn/core/public';
import type { HttpStart } from '@kbn/core-http-browser';
import { BASE_ALERTING_API_PATH } from '../constants';
export interface MuteAlertInstanceParams {
id: string;
instanceId: string;
http: HttpSetup;
http: HttpStart;
}
export const muteAlertInstance = ({ id, instanceId, http }: MuteAlertInstanceParams) => {

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { unmuteAlertInstance } from './unmute_alert_instance';
const http = httpServiceMock.createStartContract();

View file

@ -7,13 +7,13 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { HttpSetup } from '@kbn/core/public';
import type { HttpStart } from '@kbn/core-http-browser';
import { BASE_ALERTING_API_PATH } from '../constants';
export interface UnmuteAlertInstanceParams {
id: string;
instanceId: string;
http: HttpSetup;
http: HttpStart;
}
export const unmuteAlertInstance = ({ id, instanceId, http }: UnmuteAlertInstanceParams) => {

View file

@ -8,15 +8,4 @@
*/
export const BASE_ALERTING_API_PATH = '/api/alerting';
export const queryKeys = {
root: 'alerts',
mutedAlerts: (ruleIds: string[]) =>
[queryKeys.root, 'mutedInstanceIdsForRuleIds', ruleIds] as const,
};
export const mutationKeys = {
root: 'alerts',
muteAlertInstance: () => [mutationKeys.root, 'muteAlertInstance'] as const,
unmuteAlertInstance: () => [mutationKeys.root, 'unmuteAlertInstance'] as const,
};
export const INTERNAL_BASE_ALERTING_API_PATH = '/internal/alerting';

View file

@ -13,7 +13,7 @@ import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/al
import { QueryOptionsOverrides } from '@kbn/alerts-ui-shared/src/common/types/tanstack_query_utility_types';
import type { HttpStart } from '@kbn/core-http-browser';
import type { NotificationsStart } from '@kbn/core-notifications-browser';
import { queryKeys } from '../constants';
import { queryKeys } from '../query_keys';
import { MutedAlerts, ServerError } from '../types';
import {
getMutedAlertsInstancesByRule,
@ -38,13 +38,15 @@ export interface UseGetMutedAlertsQueryParams {
notifications: NotificationsStart;
}
export const getKey = queryKeys.getMutedAlerts;
export const useGetMutedAlertsQuery = (
{ ruleIds, http, notifications: { toasts } }: UseGetMutedAlertsQueryParams,
{ enabled }: QueryOptionsOverrides<typeof getMutedAlerts> = {}
) => {
return useQuery({
context: AlertsQueryContext,
queryKey: queryKeys.mutedAlerts(ruleIds),
queryKey: getKey(ruleIds),
queryFn: ({ signal }) => getMutedAlerts({ http, signal, ruleIds }),
onError: (error: ServerError) => {
if (error.name !== 'AbortError') {

View file

@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n';
import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
import type { HttpStart } from '@kbn/core-http-browser';
import type { NotificationsStart } from '@kbn/core-notifications-browser';
import { mutationKeys } from '../constants';
import { mutationKeys } from '../mutation_keys';
import type { ServerError, ToggleAlertParams } from '../types';
import { muteAlertInstance } from '../apis/mute_alert_instance';
@ -25,6 +25,8 @@ export interface UseMuteAlertInstanceParams {
notifications: NotificationsStart;
}
export const getKey = mutationKeys.muteAlertInstance;
export const useMuteAlertInstance = ({
http,
notifications: { toasts },
@ -33,7 +35,7 @@ export const useMuteAlertInstance = ({
({ ruleId, alertInstanceId }: ToggleAlertParams) =>
muteAlertInstance({ http, id: ruleId, instanceId: alertInstanceId }),
{
mutationKey: mutationKeys.muteAlertInstance(),
mutationKey: getKey(),
context: AlertsQueryContext,
onSuccess() {
toasts.addSuccess(

View file

@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n';
import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
import type { HttpStart } from '@kbn/core-http-browser';
import type { NotificationsStart } from '@kbn/core-notifications-browser';
import { mutationKeys } from '../constants';
import { mutationKeys } from '../mutation_keys';
import type { ServerError, ToggleAlertParams } from '../types';
import { unmuteAlertInstance } from '../apis/unmute_alert_instance';
@ -25,6 +25,8 @@ export interface UseUnmuteAlertInstanceParams {
notifications: NotificationsStart;
}
export const getKey = mutationKeys.unmuteAlertInstance;
export const useUnmuteAlertInstance = ({
http,
notifications: { toasts },
@ -33,7 +35,7 @@ export const useUnmuteAlertInstance = ({
({ ruleId, alertInstanceId }: ToggleAlertParams) =>
unmuteAlertInstance({ http, id: ruleId, instanceId: alertInstanceId }),
{
mutationKey: mutationKeys.unmuteAlertInstance(),
mutationKey: getKey(),
context: AlertsQueryContext,
onSuccess() {
toasts.addSuccess(

View file

@ -0,0 +1,14 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const mutationKeys = {
root: 'alerts',
muteAlertInstance: () => [mutationKeys.root, 'muteAlertInstance'] as const,
unmuteAlertInstance: () => [mutationKeys.root, 'unmuteAlertInstance'] as const,
};

View file

@ -0,0 +1,14 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const queryKeys = {
root: 'alerts',
getMutedAlerts: (ruleIds: string[]) =>
[queryKeys.root, 'mutedInstanceIdsForRuleIds', ruleIds] as const,
};

View file

@ -15,7 +15,6 @@
"target/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/core-http-browser",
"@kbn/i18n",
"@kbn/alerts-ui-shared",

View file

@ -43,7 +43,7 @@ import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/al
import deepEqual from 'fast-deep-equal';
import { Alert } from '@kbn/alerting-types';
import { useGetMutedAlertsQuery } from '@kbn/response-ops-alerts-apis/hooks/use_get_muted_alerts_query';
import { queryKeys as alertsQueryKeys } from '@kbn/response-ops-alerts-apis/constants';
import { queryKeys as alertsQueryKeys } from '@kbn/response-ops-alerts-apis/query_keys';
import { ErrorFallback } from './error_fallback';
import { defaultAlertsTableColumns } from '../configuration';
import { Storage } from '../utils/storage';
@ -357,7 +357,7 @@ const AlertsTableContent = typedForwardRef(
refetchAlerts();
}
queryClient.invalidateQueries(queryKeys.casesBulkGet(caseIds));
queryClient.invalidateQueries(alertsQueryKeys.mutedAlerts(ruleIds));
queryClient.invalidateQueries(alertsQueryKeys.getMutedAlerts(ruleIds));
queryClient.invalidateQueries(queryKeys.maintenanceWindowsBulkGet(maintenanceWindowIds));
}, [caseIds, maintenanceWindowIds, queryClient, queryParams.pageIndex, refetchAlerts, ruleIds]);

View file

@ -16,9 +16,8 @@ import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { createPartialObjectMock } from '../utils/test';
import { AlertsTableContextProvider } from '../contexts/alerts_table_context';
jest.mock('@kbn/alerts-ui-shared/src/common/hooks/use_load_rule_types_query', () => ({
useLoadRuleTypesQuery: jest.fn(),
}));
jest.mock('@kbn/alerts-ui-shared/src/common/hooks/use_get_rule_types_permissions');
jest.mock('./view_rule_details_alert_action', () => {
return {
ViewRuleDetailsAlertAction: () => (
@ -44,8 +43,8 @@ jest.mock('./mark_as_untracked_alert_action', () => {
};
});
const { useLoadRuleTypesQuery } = jest.requireMock(
'@kbn/alerts-ui-shared/src/common/hooks/use_load_rule_types_query'
const { useGetRuleTypesPermissions } = jest.requireMock(
'@kbn/alerts-ui-shared/src/common/hooks/use_get_rule_types_permissions'
);
const http = httpServiceMock.createStartContract();
@ -70,7 +69,7 @@ const TestComponent = (_props: AlertActionsProps) => (
describe('DefaultAlertActions', () => {
it('should show "Mute" and "Marked as untracked" option', async () => {
useLoadRuleTypesQuery.mockReturnValue({ authorizedToCreateAnyRules: true });
useGetRuleTypesPermissions.mockReturnValue({ authorizedToCreateAnyRules: true });
render(<TestComponent {...props} />);
@ -79,7 +78,7 @@ describe('DefaultAlertActions', () => {
});
it('should hide "Mute" and "Marked as untracked" option', async () => {
useLoadRuleTypesQuery.mockReturnValue({ authorizedToCreateAnyRules: false });
useGetRuleTypesPermissions.mockReturnValue({ authorizedToCreateAnyRules: false });
render(<TestComponent {...props} />);

View file

@ -8,7 +8,7 @@
*/
import React from 'react';
import { useLoadRuleTypesQuery } from '@kbn/alerts-ui-shared/src/common/hooks';
import { useGetRuleTypesPermissions } from '@kbn/alerts-ui-shared/src/common/hooks';
import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
import { ViewRuleDetailsAlertAction } from './view_rule_details_alert_action';
import type { AdditionalContext, AlertActionsProps } from '../types';
@ -29,7 +29,7 @@ export const DefaultAlertActions = <AC extends AdditionalContext = AdditionalCon
notifications: { toasts },
},
} = useAlertsTableContext();
const { authorizedToCreateAnyRules } = useLoadRuleTypesQuery({
const { authorizedToCreateAnyRules } = useGetRuleTypesPermissions({
filteredRuleTypes: [],
http,
toasts,

View file

@ -29,6 +29,7 @@ const rewriteBodyReq: RewriteRequestCase<RuleType> = ({
default_schedule_interval: defaultScheduleInterval,
has_alerts_mappings: hasAlertsMappings,
has_fields_for_a_a_d: hasFieldsForAAD,
is_exportable: isExportable,
...rest
}: AsApiContract<RuleType>) => ({
enabledInLicense,
@ -43,6 +44,7 @@ const rewriteBodyReq: RewriteRequestCase<RuleType> = ({
defaultScheduleInterval,
hasAlertsMappings,
hasFieldsForAAD,
isExportable,
...rest,
});

View file

@ -29,8 +29,8 @@ jest.mock('../common/hooks/use_resolve_rule', () => ({
useResolveRule: jest.fn(),
}));
jest.mock('@kbn/alerts-ui-shared/src/common/hooks/use_load_rule_types_query', () => ({
useLoadRuleTypesQuery: jest.fn(),
jest.mock('@kbn/alerts-ui-shared/src/common/hooks/use_get_rule_types_permissions', () => ({
useGetRuleTypesPermissions: jest.fn(),
}));
jest.mock('../common/hooks/use_load_connectors', () => ({
@ -59,8 +59,8 @@ const { useLoadConnectorTypes } = jest.requireMock('../common/hooks/use_load_con
const { useLoadRuleTypeAadTemplateField } = jest.requireMock(
'../common/hooks/use_load_rule_type_aad_template_fields'
);
const { useLoadRuleTypesQuery } = jest.requireMock(
'@kbn/alerts-ui-shared/src/common/hooks/use_load_rule_types_query'
const { useGetRuleTypesPermissions } = jest.requireMock(
'@kbn/alerts-ui-shared/src/common/hooks/use_get_rule_types_permissions'
);
const { useFetchFlappingSettings } = jest.requireMock(
'@kbn/alerts-ui-shared/src/common/hooks/use_fetch_flapping_settings'
@ -158,7 +158,7 @@ const ruleTypeIndex = new Map();
ruleTypeIndex.set('.index-threshold', indexThresholdRuleType);
useLoadRuleTypesQuery.mockReturnValue({
useGetRuleTypesPermissions.mockReturnValue({
ruleTypesState: {
isLoading: false,
isInitialLoading: false,
@ -278,7 +278,7 @@ describe('useLoadDependencies', () => {
});
});
test('should call useLoadRuleTypesQuery with fitlered rule types', async () => {
test('should call useGetRuleTypesPermissions with filtered rule types', async () => {
const { result } = renderHook(
() => {
return useLoadDependencies({
@ -302,7 +302,7 @@ describe('useLoadDependencies', () => {
return expect(result.current.isInitialLoading).toEqual(false);
});
expect(useLoadRuleTypesQuery).toBeCalledWith({
expect(useGetRuleTypesPermissions).toBeCalledWith({
http: httpMock,
toasts: toastsMock,
filteredRuleTypes: ['test-rule-type'],

View file

@ -14,7 +14,7 @@ import type { RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import { useMemo } from 'react';
import {
useHealthCheck,
useLoadRuleTypesQuery,
useGetRuleTypesPermissions,
useFetchFlappingSettings,
} from '@kbn/alerts-ui-shared';
import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public';
@ -81,7 +81,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => {
isLoading: isLoadingRuleTypes,
isInitialLoad: isInitialLoadingRuleTypes,
},
} = useLoadRuleTypesQuery({
} = useGetRuleTypesPermissions({
http,
toasts,
filteredRuleTypes,

View file

@ -27,6 +27,7 @@ const mockRuleType: (
actionGroups: [],
defaultActionGroupId: 'default',
category: 'my-category',
isExportable: true,
});
const pickRuleTypeName = (ruleType: RuleTypeWithDescription) => ({ name: ruleType.name });

View file

@ -11,7 +11,7 @@ import { countBy } from 'lodash';
import React, { useMemo, useState } from 'react';
import type { HttpStart } from '@kbn/core-http-browser';
import type { ToastsStart } from '@kbn/core-notifications-browser';
import { useLoadRuleTypesQuery } from '@kbn/alerts-ui-shared';
import { useGetRuleTypesPermissions } from '@kbn/alerts-ui-shared';
import { RuleTypeModal, type RuleTypeModalProps } from './rule_type_modal';
import { filterAndCountRuleTypes } from './helpers/filter_and_count_rule_types';
@ -38,7 +38,7 @@ export const RuleTypeModalComponent: React.FC<RuleTypeModalComponentProps> = ({
const {
ruleTypesState: { data: ruleTypeIndex, isLoading: ruleTypesLoading },
} = useLoadRuleTypesQuery({
} = useGetRuleTypesPermissions({
http,
toasts,
filteredRuleTypes,

View file

@ -32,6 +32,7 @@ const ruleTypes: RuleTypeWithDescription[] = [
},
defaultActionGroupId: '1',
category: 'my-category-1',
isExportable: true,
},
{
id: '2',
@ -51,6 +52,7 @@ const ruleTypes: RuleTypeWithDescription[] = [
},
defaultActionGroupId: '2',
category: 'my-category-2',
isExportable: true,
},
{
id: '3',
@ -70,6 +72,7 @@ const ruleTypes: RuleTypeWithDescription[] = [
},
defaultActionGroupId: '3',
category: 'my-category-3',
isExportable: true,
},
];

View file

@ -43,6 +43,7 @@ describe('getInitialMultiConsumer', () => {
},
enabledInLicense: true,
category: 'test',
isExportable: true,
} as RuleTypeWithDescription;
const ruleTypes = [
@ -100,6 +101,7 @@ describe('getInitialMultiConsumer', () => {
name: 'Index threshold',
category: 'management',
producer: 'stackAlerts',
isExportable: true,
},
] as RuleTypeWithDescription[];

View file

@ -0,0 +1,3 @@
# @kbn/response-ops-rules-apis
Client-side Rules HTTP API fetchers and React Query wrappers.

View file

@ -0,0 +1,97 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getInternalRuleTypes } from './get_internal_rule_types';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
const http = httpServiceMock.createStartContract();
const ruleTypeResponse = {
id: '1',
name: 'name',
action_groups: [
{
id: 'default',
name: 'Default',
},
],
action_variables: {
context: [],
state: [],
},
alerts: [],
authorized_consumers: {},
category: 'test',
default_action_group_id: 'default',
default_schedule_interval: '10m',
does_set_recovery_context: false,
enabled_in_license: true,
has_alerts_mappings: true,
has_fields_for_a_a_d: false,
is_exportable: true,
minimum_license_required: 'basic',
producer: 'test',
solution: 'stack',
recovery_action_group: {
id: 'recovered',
name: 'Recovered',
},
rule_task_timeout: '10m',
};
const expectedRuleType = {
id: '1',
name: 'name',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
actionVariables: {
context: [],
state: [],
},
alerts: [],
authorizedConsumers: {},
category: 'test',
defaultActionGroupId: 'default',
defaultScheduleInterval: '10m',
doesSetRecoveryContext: false,
enabledInLicense: true,
hasAlertsMappings: true,
hasFieldsForAAD: false,
isExportable: true,
minimumLicenseRequired: 'basic',
producer: 'test',
solution: 'stack',
recoveryActionGroup: {
id: 'recovered',
name: 'Recovered',
},
ruleTaskTimeout: '10m',
};
describe('getInternalRuleTypes', () => {
it('should call the internal rule types API and transform the response case', async () => {
http.get.mockResolvedValueOnce([ruleTypeResponse]);
const result = await getInternalRuleTypes({
http,
});
expect(result).toEqual([expectedRuleType]);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/internal/alerting/_rule_types",
]
`);
});
});

View file

@ -0,0 +1,61 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { AsApiContract, RewriteRequestCase } from '@kbn/actions-types';
import type { RuleType } from '@kbn/triggers-actions-ui-types';
import type { HttpStart } from '@kbn/core-http-browser';
import type { RuleTypeSolution } from '@kbn/alerting-types';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../constants';
export interface InternalRuleType extends RuleType<string, string> {
solution: RuleTypeSolution;
}
const rewriteResponse = (results: Array<AsApiContract<InternalRuleType>>): InternalRuleType[] => {
return results.map((item) => rewriteRuleType(item));
};
const rewriteRuleType: RewriteRequestCase<InternalRuleType> = ({
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,
is_exportable: isExportable,
...rest
}: AsApiContract<InternalRuleType>) => ({
enabledInLicense,
recoveryActionGroup,
actionGroups,
defaultActionGroupId,
minimumLicenseRequired,
actionVariables,
authorizedConsumers,
ruleTaskTimeout,
doesSetRecoveryContext,
defaultScheduleInterval,
hasAlertsMappings,
hasFieldsForAAD,
isExportable,
...rest,
});
export async function getInternalRuleTypes({ http }: { http: HttpStart }) {
const res = await http.get<Array<AsApiContract<InternalRuleType>>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/_rule_types`
);
return rewriteResponse(res);
}

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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getRuleTags } from './get_rule_tags';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
const http = httpServiceMock.createStartContract();
describe('getRuleTags', () => {
it('should call the getTags API', async () => {
const resolvedValue = {
data: ['a', 'b', 'c'],
total: 3,
page: 2,
per_page: 30,
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await getRuleTags({
http,
search: 'test',
page: 2,
perPage: 30,
ruleTypeIds: ['test-rule-type'],
});
expect(result).toEqual({
data: ['a', 'b', 'c'],
page: 2,
perPage: 30,
total: 3,
});
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/internal/alerting/rules/_tags",
Object {
"query": Object {
"page": 2,
"per_page": 30,
"rule_type_ids": Array [
"test-rule-type",
],
"search": "test",
},
},
]
`);
});
});

View file

@ -0,0 +1,59 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { HttpStart } from '@kbn/core-http-browser';
import type { AsApiContract, RewriteRequestCase } from '@kbn/actions-types';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../constants';
export interface GetRuleTagsParams {
// Params
search?: string;
ruleTypeIds?: string[];
perPage?: number;
page: number;
// Services
http: HttpStart;
}
export interface GetRuleTagsResponse {
total: number;
page: number;
perPage: number;
data: string[];
}
export const rewriteTagsBodyRes: RewriteRequestCase<GetRuleTagsResponse> = ({
per_page: perPage,
...rest
}) => ({
perPage,
...rest,
});
export async function getRuleTags({
http,
search,
ruleTypeIds,
perPage,
page,
}: GetRuleTagsParams): Promise<GetRuleTagsResponse> {
const res = await http.get<AsApiContract<GetRuleTagsResponse>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/_tags`,
{
query: {
search,
per_page: perPage,
page,
rule_type_ids: ruleTypeIds,
},
}
);
return rewriteTagsBodyRes(res);
}

View file

@ -0,0 +1,95 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getRuleTypes } from './get_rule_types';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
const http = httpServiceMock.createStartContract();
const ruleTypeResponse = {
id: '1',
name: 'name',
action_groups: [
{
id: 'default',
name: 'Default',
},
],
action_variables: {
context: [],
state: [],
},
alerts: [],
authorized_consumers: {},
category: 'test',
default_action_group_id: 'default',
default_schedule_interval: '10m',
does_set_recovery_context: false,
enabled_in_license: true,
has_alerts_mappings: true,
has_fields_for_a_a_d: false,
is_exportable: true,
minimum_license_required: 'basic',
producer: 'test',
recovery_action_group: {
id: 'recovered',
name: 'Recovered',
},
rule_task_timeout: '10m',
};
const expectedRuleType = {
id: '1',
name: 'name',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
actionVariables: {
context: [],
state: [],
},
alerts: [],
authorizedConsumers: {},
category: 'test',
defaultActionGroupId: 'default',
defaultScheduleInterval: '10m',
doesSetRecoveryContext: false,
enabledInLicense: true,
hasAlertsMappings: true,
hasFieldsForAAD: false,
isExportable: true,
minimumLicenseRequired: 'basic',
producer: 'test',
recoveryActionGroup: {
id: 'recovered',
name: 'Recovered',
},
ruleTaskTimeout: '10m',
};
describe('getRuleTypes', () => {
it('should call the rule types API and transform the response case', async () => {
http.get.mockResolvedValueOnce([ruleTypeResponse]);
const result = await getRuleTypes({
http,
});
expect(result).toEqual([expectedRuleType]);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alerting/rule_types",
]
`);
});
});

View file

@ -7,16 +7,16 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { HttpSetup } from '@kbn/core/public';
import type { HttpStart } from '@kbn/core-http-browser';
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 rewriteResponse = (results: Array<AsApiContract<RuleType>>): RuleType[] => {
return results.map((item) => rewriteRuleType(item));
};
const rewriteBodyReq: RewriteRequestCase<RuleType> = ({
const rewriteRuleType: RewriteRequestCase<RuleType> = ({
enabled_in_license: enabledInLicense,
recovery_action_group: recoveryActionGroup,
action_groups: actionGroups,
@ -29,6 +29,7 @@ const rewriteBodyReq: RewriteRequestCase<RuleType> = ({
default_schedule_interval: defaultScheduleInterval,
has_alerts_mappings: hasAlertsMappings,
has_fields_for_a_a_d: hasFieldsForAAD,
is_exportable: isExportable,
...rest
}: AsApiContract<RuleType>) => ({
enabledInLicense,
@ -43,12 +44,13 @@ const rewriteBodyReq: RewriteRequestCase<RuleType> = ({
defaultScheduleInterval,
hasAlertsMappings,
hasFieldsForAAD,
isExportable,
...rest,
});
export async function fetchRuleTypes({ http }: { http: HttpSetup }): Promise<RuleType[]> {
export async function getRuleTypes({ http }: { http: HttpStart }): Promise<RuleType[]> {
const res = await http.get<Array<AsApiContract<RuleType<string, string>>>>(
`${BASE_ALERTING_API_PATH}/rule_types`
);
return rewriteResponseRes(res);
return rewriteResponse(res);
}

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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const BASE_ALERTING_API_PATH = '/api/alerting';
export const INTERNAL_BASE_ALERTING_API_PATH = '/internal/alerting';

View file

@ -0,0 +1,60 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { PropsWithChildren } from 'react';
import { waitFor, renderHook } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useGetInternalRuleTypesQuery } from './use_get_internal_rule_types_query';
import { InternalRuleType, getInternalRuleTypes } from '../apis/get_internal_rule_types';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { testQueryClientConfig } from '../test_utils';
const mockInternalRuleTypes = [
{ id: 'a' },
{ id: 'b' },
{ id: 'c' },
] as unknown as InternalRuleType[];
jest.mock('../apis/get_internal_rule_types');
const mockGetInternalRuleTypes = jest.mocked(getInternalRuleTypes);
const http = httpServiceMock.createStartContract();
const queryClient = new QueryClient(testQueryClientConfig);
export const Wrapper = ({ children }: PropsWithChildren) => {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
describe('useGetInternalRuleTypesQuery', () => {
beforeEach(() => {
mockGetInternalRuleTypes.mockResolvedValue(mockInternalRuleTypes);
});
afterEach(() => {
queryClient.clear();
jest.clearAllMocks();
});
it('should call the getInternalRuleTypes API', async () => {
const { result } = renderHook(
() =>
useGetInternalRuleTypesQuery({
http,
}),
{
wrapper: Wrapper,
}
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(mockGetInternalRuleTypes).toHaveBeenCalled();
expect(result.current.data).toEqual(mockInternalRuleTypes);
});
});

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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { HttpStart } from '@kbn/core-http-browser';
import { useQuery } from '@tanstack/react-query';
import { getInternalRuleTypes } from '../apis/get_internal_rule_types';
import { queryKeys } from '../query_keys';
export const getKey = queryKeys.getInternalRuleTypes;
export const useGetInternalRuleTypesQuery = ({ http }: { http: HttpStart }) => {
return useQuery({
queryKey: getKey(),
queryFn: () => getInternalRuleTypes({ http }),
});
};

View file

@ -1,44 +1,38 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { waitFor, renderHook } from '@testing-library/react';
import { useLoadTagsQuery } from './use_load_tags_query';
import { useGetRuleTagsQuery } from './use_get_rule_tags_query';
import { getRuleTags } from '../apis/get_rule_tags';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useKibana } from '../../common/lib/kibana';
import { IToasts } from '@kbn/core-notifications-browser';
import { testQueryClientConfig } from '../test_utils';
import React, { PropsWithChildren } from 'react';
const MOCK_TAGS = ['a', 'b', 'c'];
jest.mock('../../common/lib/kibana');
jest.mock('../lib/rule_api/aggregate', () => ({
loadRuleTags: jest.fn(),
}));
jest.mock('../apis/get_rule_tags');
const mockGetRuleTags = jest.mocked(getRuleTags);
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const { loadRuleTags } = jest.requireMock('../lib/rule_api/aggregate');
const http = httpServiceMock.createStartContract();
const notifications = notificationServiceMock.createStartContract();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const queryClient = new QueryClient(testQueryClientConfig);
describe('useLoadTagsQuery', () => {
export const Wrapper = ({ children }: PropsWithChildren) => {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
describe('useGetRuleTagsQuery', () => {
beforeEach(() => {
useKibanaMock().services.notifications.toasts = {
addDanger: jest.fn(),
} as unknown as IToasts;
loadRuleTags.mockResolvedValue({
mockGetRuleTags.mockResolvedValue({
data: MOCK_TAGS,
page: 1,
perPage: 50,
@ -51,23 +45,25 @@ describe('useLoadTagsQuery', () => {
jest.clearAllMocks();
});
it('should call loadRuleTags API and handle result', async () => {
it('should call the getRuleTags API and collect the tags into one array', async () => {
const { rerender, result } = renderHook(
() =>
useLoadTagsQuery({
useGetRuleTagsQuery({
http,
toasts: notifications.toasts,
enabled: true,
search: 'test',
perPage: 50,
page: 1,
}),
{
wrapper,
wrapper: Wrapper,
}
);
rerender();
await waitFor(() => {
expect(loadRuleTags).toHaveBeenLastCalledWith(
expect(mockGetRuleTags).toHaveBeenLastCalledWith(
expect.objectContaining({
search: 'test',
perPage: 50,
@ -81,7 +77,7 @@ describe('useLoadTagsQuery', () => {
});
it('should support pagination', async () => {
loadRuleTags.mockResolvedValue({
mockGetRuleTags.mockResolvedValue({
data: ['a', 'b', 'c', 'd', 'e'],
page: 1,
perPage: 5,
@ -89,19 +85,21 @@ describe('useLoadTagsQuery', () => {
});
const { rerender, result } = renderHook(
() =>
useLoadTagsQuery({
useGetRuleTagsQuery({
http,
toasts: notifications.toasts,
enabled: true,
perPage: 5,
page: 1,
}),
{
wrapper,
wrapper: Wrapper,
}
);
rerender();
await waitFor(() => {
expect(loadRuleTags).toHaveBeenLastCalledWith(
expect(mockGetRuleTags).toHaveBeenLastCalledWith(
expect.objectContaining({
perPage: 5,
page: 1,
@ -112,7 +110,7 @@ describe('useLoadTagsQuery', () => {
expect(result.current.hasNextPage).toEqual(true);
});
loadRuleTags.mockResolvedValue({
mockGetRuleTags.mockResolvedValue({
data: ['a', 'b', 'c', 'd', 'e'],
page: 2,
perPage: 5,
@ -121,7 +119,7 @@ describe('useLoadTagsQuery', () => {
result.current.fetchNextPage();
expect(loadRuleTags).toHaveBeenLastCalledWith(
expect(mockGetRuleTags).toHaveBeenLastCalledWith(
expect.objectContaining({
perPage: 5,
page: 2,
@ -133,7 +131,7 @@ describe('useLoadTagsQuery', () => {
});
it('should support pagination when there are no tags', async () => {
loadRuleTags.mockResolvedValue({
mockGetRuleTags.mockResolvedValue({
data: [],
page: 1,
perPage: 5,
@ -142,19 +140,21 @@ describe('useLoadTagsQuery', () => {
const { rerender, result } = renderHook(
() =>
useLoadTagsQuery({
useGetRuleTagsQuery({
http,
toasts: notifications.toasts,
enabled: true,
perPage: 5,
page: 1,
}),
{
wrapper,
wrapper: Wrapper,
}
);
rerender();
await waitFor(() => {
expect(loadRuleTags).toHaveBeenLastCalledWith(
expect(mockGetRuleTags).toHaveBeenLastCalledWith(
expect.objectContaining({
perPage: 5,
page: 1,
@ -167,14 +167,20 @@ describe('useLoadTagsQuery', () => {
});
it('should call onError if API fails', async () => {
loadRuleTags.mockRejectedValue('');
mockGetRuleTags.mockRejectedValue('');
const { result } = renderHook(() => useLoadTagsQuery({ enabled: true }), { wrapper });
expect(loadRuleTags).toBeCalled();
expect(result.current.tags).toEqual([]);
await waitFor(() =>
expect(useKibanaMock().services.notifications.toasts.addDanger).toBeCalled()
const { result } = renderHook(
() =>
useGetRuleTagsQuery({
http,
toasts: notifications.toasts,
enabled: true,
}),
{ wrapper: Wrapper }
);
expect(mockGetRuleTags).toBeCalled();
expect(result.current.tags).toEqual([]);
await waitFor(() => expect(notifications.toasts.addDanger).toBeCalled());
});
});

View file

@ -1,58 +1,58 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { useInfiniteQuery } from '@tanstack/react-query';
import { loadRuleTags } from '../lib/rule_api/aggregate';
import { useKibana } from '../../common/lib/kibana';
import type { LoadRuleTagsProps } from '../lib/rule_api';
import type { GetRuleTagsResponse } from '../lib/rule_api/aggregate_helpers';
import type { ToastsStart } from '@kbn/core-notifications-browser';
import type { SetOptional } from 'type-fest';
import type { GetRuleTagsParams, GetRuleTagsResponse } from '../apis/get_rule_tags';
import { getRuleTags } from '../apis/get_rule_tags';
import { queryKeys } from '../query_keys';
interface UseLoadTagsQueryProps {
enabled: boolean;
interface UseGetRuleTagsQueryParams extends SetOptional<GetRuleTagsParams, 'page'> {
// Params
refresh?: Date;
search?: string;
perPage?: number;
page?: number;
enabled: boolean;
// Services
toasts: ToastsStart;
}
const EMPTY_TAGS: string[] = [];
export const getKey = queryKeys.getRuleTags;
// React query will refetch all prev pages when the cache keys change:
// https://github.com/TanStack/query/discussions/3576
export function useLoadTagsQuery(props: UseLoadTagsQueryProps) {
const { enabled, refresh, search, perPage, page = 1 } = props;
const {
http,
notifications: { toasts },
} = useKibana().services;
const queryFn = ({ pageParam }: { pageParam?: LoadRuleTagsProps }) => {
if (pageParam) {
return loadRuleTags({
http,
perPage: pageParam.perPage,
page: pageParam.page,
search,
});
}
return loadRuleTags({
export function useGetRuleTagsQuery({
enabled,
refresh,
search,
ruleTypeIds,
perPage,
page = 1,
http,
toasts,
}: UseGetRuleTagsQueryParams) {
const queryFn = ({ pageParam }: { pageParam?: GetRuleTagsParams }) =>
getRuleTags({
http,
perPage,
page,
perPage: pageParam?.perPage ?? perPage,
page: pageParam?.page ?? page,
search,
ruleTypeIds,
});
};
const onErrorFn = () => {
toasts.addDanger(
i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags', {
i18n.translate('responseOpsRulesApis.unableToLoadRuleTags', {
defaultMessage: 'Unable to load rule tags',
})
);
@ -71,15 +71,13 @@ export function useLoadTagsQuery(props: UseLoadTagsQueryProps) {
const { refetch, data, fetchNextPage, isLoading, isFetching, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: [
'loadRuleTags',
queryKey: getKey({
ruleTypeIds,
search,
perPage,
page,
{
refresh: refresh?.toISOString(),
},
],
refresh,
}),
queryFn,
onError: onErrorFn,
enabled,

View file

@ -0,0 +1,60 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { PropsWithChildren } from 'react';
import { waitFor, renderHook } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useGetRuleTypesQuery } from './use_get_rule_types_query';
import type { RuleType } from '@kbn/triggers-actions-ui-types';
import { getRuleTypes } from '../apis/get_rule_types';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { testQueryClientConfig } from '../test_utils';
const mockRuleTypes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] as unknown as RuleType[];
jest.mock('../apis/get_rule_types');
const mockGetRuleTypes = jest.mocked(getRuleTypes);
const http = httpServiceMock.createStartContract();
const queryClient = new QueryClient(testQueryClientConfig);
export const Wrapper = ({ children }: PropsWithChildren) => {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
describe('useGetRuleTypesQuery', () => {
beforeEach(() => {
mockGetRuleTypes.mockResolvedValue(mockRuleTypes);
});
afterEach(() => {
queryClient.clear();
jest.clearAllMocks();
});
it('should call the getRuleTypes API', async () => {
const { result } = renderHook(
() =>
useGetRuleTypesQuery(
{
http,
},
{ enabled: true }
),
{
wrapper: Wrapper,
}
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(mockGetRuleTypes).toHaveBeenCalled();
expect(result.current.data).toEqual(mockRuleTypes);
});
});

View file

@ -0,0 +1,41 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { HttpStart } from '@kbn/core-http-browser';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { getRuleTypes } from '../apis/get_rule_types';
import { queryKeys } from '../query_keys';
export interface GetRuleTypesQueryParams {
http: HttpStart;
}
export const getKey = queryKeys.getRuleTypes;
export const useGetRuleTypesQuery = (
{ http }: GetRuleTypesQueryParams,
{
onError,
enabled,
context,
}: Pick<UseQueryOptions<typeof getRuleTypes>, 'onError' | 'enabled' | 'context'>
) => {
return useQuery({
queryKey: getKey(),
queryFn: () => getRuleTypes({ http }),
refetchOnWindowFocus: false,
// Leveraging TanStack Query's caching system to avoid duplicated requests as
// other state-sharing solutions turned out to be overly complex and less readable
staleTime: 60 * 1000,
onError,
enabled,
context,
});
};

View file

@ -0,0 +1,17 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../../..',
roots: ['<rootDir>/src/platform/packages/shared/response-ops/rules-apis'],
setupFilesAfterEnv: [
'<rootDir>/src/platform/packages/shared/response-ops/rules-apis/setup_tests.ts',
],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-browser",
"id": "@kbn/response-ops-rules-apis",
"owner": "@elastic/response-ops",
"group": "platform",
"visibility": "shared"
}

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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const mutationKeys = {
root: 'rules',
};

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/response-ops-rules-apis",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,38 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const queryKeys = {
root: 'rules',
getRuleTags: ({
ruleTypeIds,
search,
perPage,
page,
refresh,
}: {
ruleTypeIds?: string[];
search?: string;
perPage?: number;
page: number;
refresh?: Date;
}) =>
[
queryKeys.root,
'getRuleTags',
ruleTypeIds,
search,
perPage,
page,
{
refresh: refresh?.toISOString(),
},
] as const,
getRuleTypes: () => [queryKeys.root, 'getRuleTypes'] as const,
getInternalRuleTypes: () => [queryKeys.root, 'getInternalRuleTypes'] as const,
};

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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
/* eslint-disable import/no-extraneous-dependencies */
import '@testing-library/jest-dom';
import 'jest-styled-components';

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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
/* eslint-disable no-console */
export const testQueryClientConfig = {
defaultOptions: {
queries: {
retry: false,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {},
},
};

View file

@ -0,0 +1,27 @@
{
"extends": "../../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core-http-browser-mocks",
"@kbn/core-http-browser",
"@kbn/actions-types",
"@kbn/i18n",
"@kbn/core-notifications-browser",
"@kbn/core-notifications-browser-mocks",
"@kbn/triggers-actions-ui-types",
"@kbn/alerting-types",
]
}

View file

@ -1524,6 +1524,8 @@
"@kbn/response-ops-rule-form/*": ["src/platform/packages/shared/response-ops/rule_form/*"],
"@kbn/response-ops-rule-params": ["src/platform/packages/shared/response-ops/rule_params"],
"@kbn/response-ops-rule-params/*": ["src/platform/packages/shared/response-ops/rule_params/*"],
"@kbn/response-ops-rules-apis": ["src/platform/packages/shared/response-ops/rules-apis"],
"@kbn/response-ops-rules-apis/*": ["src/platform/packages/shared/response-ops/rules-apis/*"],
"@kbn/response-stream-plugin": ["examples/response_stream"],
"@kbn/response-stream-plugin/*": ["examples/response_stream/*"],
"@kbn/rison": ["src/platform/packages/shared/kbn-rison"],

View file

@ -35,13 +35,6 @@ const { loadActionTypes } = jest.requireMock(
'@kbn/triggers-actions-ui-plugin/public/application/lib/action_connector_api'
);
jest.mock(
'@kbn/triggers-actions-ui-plugin/public/application/hooks/use_load_rule_types_query',
() => ({
useLoadRuleTypesQuery: jest.fn(),
})
);
jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({
useUiSetting: jest.fn().mockImplementation((_, defaultValue) => defaultValue),
}));

View file

@ -50307,8 +50307,6 @@
"xpack.triggersActionsUI.sections.rulesList.unableToLoadConnectorTypesMessage": "Impossible de charger les types de connecteurs",
"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.viewBannerButtonLabel": "Afficher {totalStatusesError, plural, one {la règle} other {les règles}} comportant des erreurs",
"xpack.triggersActionsUI.sections.rulesList.weeksLabel": "semaines",

View file

@ -50267,8 +50267,6 @@
"xpack.triggersActionsUI.sections.rulesList.unableToLoadConnectorTypesMessage": "コネクタータイプを読み込めません",
"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.viewBannerButtonLabel": "エラーがある{totalStatusesError, plural, other {個のルール}}を表示",
"xpack.triggersActionsUI.sections.rulesList.weeksLabel": "週",

View file

@ -50350,8 +50350,6 @@
"xpack.triggersActionsUI.sections.rulesList.unableToLoadConnectorTypesMessage": "无法加载连接器类型",
"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.viewBannerButtonLabel": "显示包含错误的{totalStatusesError, plural, other {规则}}",
"xpack.triggersActionsUI.sections.rulesList.weeksLabel": "周",

View file

@ -0,0 +1,191 @@
/*
* 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 { schema } from '@kbn/config-schema';
const actionVariableSchema = schema.object({
name: schema.string(),
description: schema.string(),
usesPublicBaseUrl: schema.maybe(schema.boolean()),
});
const actionGroupSchema = schema.object(
{
id: schema.string(),
name: schema.string(),
},
{
meta: {
description:
'An action group to use when an alert goes from an active state to an inactive one.',
},
}
);
export const typesRulesSchema = schema.object({
action_groups: schema.maybe(
schema.arrayOf(actionGroupSchema, {
meta: {
description:
"An explicit list of groups for which the rule type can schedule actions, each with the action group's unique ID and human readable name. Rule actions validation uses this configuration to ensure that groups are valid.",
},
})
),
action_variables: schema.maybe(
schema.object(
{
context: schema.maybe(schema.arrayOf(actionVariableSchema)),
state: schema.maybe(schema.arrayOf(actionVariableSchema)),
params: schema.maybe(schema.arrayOf(actionVariableSchema)),
},
{
meta: {
description:
'A list of action variables that the rule type makes available via context and state in action parameter templates, and a short human readable description. When you create a rule in Kibana, it uses this information to prompt you for these variables in action parameter editors.',
},
}
)
),
alerts: schema.maybe(
schema.object(
{
context: schema.string({
meta: {
description: 'The namespace for this rule type.',
},
}),
mappings: schema.maybe(
schema.object({
dynamic: schema.maybe(
schema.oneOf([schema.literal(false), schema.literal('strict')], {
meta: {
description: 'Indicates whether new fields are added dynamically.',
},
})
),
fieldMap: schema.recordOf(schema.string(), schema.any(), {
meta: {
description:
'Mapping information for each field supported in alerts as data documents for this rule type. For more information about mapping parameters, refer to the Elasticsearch documentation.',
},
}),
shouldWrite: schema.maybe(
schema.boolean({
meta: {
description: 'Indicates whether the rule should write out alerts as data.',
},
})
),
useEcs: schema.maybe(
schema.boolean({
meta: {
description:
'Indicates whether to include the ECS component template for the alerts.',
},
})
),
})
),
},
{
meta: {
description: 'Details for writing alerts as data documents for this rule type.',
},
}
)
),
authorized_consumers: schema.recordOf(
schema.string(),
schema.object({ read: schema.boolean(), all: schema.boolean() }),
{
meta: {
description: 'The list of the plugins IDs that have access to the rule type.',
},
}
),
category: schema.string({
meta: {
description:
'The rule category, which is used by features such as category-specific maintenance windows.',
},
}),
default_action_group_id: schema.string({
meta: {
description: 'The default identifier for the rule type group.',
},
}),
default_schedule_interval: schema.maybe(schema.string()),
does_set_recovery_context: schema.maybe(
schema.boolean({
meta: {
description: 'Indicates whether the rule passes context variables to its recovery action.',
},
})
),
enabled_in_license: schema.boolean({
meta: {
description:
'Indicates whether the rule type is enabled or disabled based on the subscription.',
},
}),
fieldsForAAD: schema.maybe(schema.arrayOf(schema.string())),
has_alerts_mappings: schema.boolean({
meta: {
description: 'Indicates whether the rule type has custom mappings for the alert data.',
},
}),
has_fields_for_a_a_d: schema.boolean({
meta: {
description:
'Indicates whether the rule type has fields for alert as data for the alert data. ',
},
}),
id: schema.string({
meta: {
description: 'The unique identifier for the rule type.',
},
}),
is_exportable: schema.boolean({
meta: {
description:
'Indicates whether the rule type is exportable in Stack Management > Saved Objects.',
},
}),
minimum_license_required: schema.oneOf(
[
schema.literal('basic'),
schema.literal('gold'),
schema.literal('platinum'),
schema.literal('standard'),
schema.literal('enterprise'),
schema.literal('trial'),
],
{
meta: {
description: 'The subscriptions required to use the rule type.',
},
}
),
name: schema.string({
meta: {
description: 'The descriptive name of the rule type.',
},
}),
producer: schema.string({
meta: {
description: 'An identifier for the application that produces this rule type.',
},
}),
recovery_action_group: actionGroupSchema,
rule_task_timeout: schema.maybe(schema.string()),
});
export const typesRulesResponseBodySchema = schema.arrayOf(typesRulesSchema);
export const typesRulesResponseSchema = schema.object({
body: typesRulesResponseBodySchema,
});

View file

@ -0,0 +1,24 @@
/*
* 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.
*/
export {
getRuleTypesInternalResponseSchema,
getRuleTypesInternalResponseBodySchema,
} from './schemas/latest';
export type {
GetRuleTypesInternalResponse,
GetRuleTypesInternalResponseBody,
} from './types/latest';
export {
getRuleTypesInternalResponseSchema as getRuleTypesInternalResponseSchemaV1,
getRuleTypesInternalResponseBodySchema as getRuleTypesInternalResponseBodySchemaV1,
} from './schemas/v1';
export type {
GetRuleTypesInternalResponse as GetRuleTypesInternalV1,
GetRuleTypesInternalResponseBody as GetRuleTypesInternalResponseBodyV1,
} from './types/v1';

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { typesRulesSchema } from '../../external/schemas/v1';
export const getRuleTypesInternalResponseBodySchema = schema.arrayOf(
typesRulesSchema.extends({
solution: schema.oneOf(
[schema.literal('stack'), schema.literal('observability'), schema.literal('security')],
{
meta: {
description: 'An identifier for the solution that owns this rule type.',
},
}
),
})
);
export const getRuleTypesInternalResponseSchema = schema.object({
body: getRuleTypesInternalResponseBodySchema,
});

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { TypeOf } from '@kbn/config-schema';
import type {
getRuleTypesInternalResponseSchemaV1,
getRuleTypesInternalResponseBodySchemaV1,
} from '..';
export type GetRuleTypesInternalResponse = TypeOf<typeof getRuleTypesInternalResponseSchemaV1>;
export type GetRuleTypesInternalResponseBody = TypeOf<
typeof getRuleTypesInternalResponseBodySchemaV1
>;

View file

@ -11,7 +11,7 @@ export const ruleTagsRequestQuerySchema = schema.object({
page: schema.number({ defaultValue: 1, min: 1 }),
per_page: schema.maybe(schema.number({ defaultValue: DEFAULT_TAGS_PER_PAGEV1, min: 1 })),
search: schema.maybe(schema.string()),
rule_type_ids: schema.maybe(schema.arrayOf(schema.string())),
rule_type_ids: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
});
export const ruleTagsFormattedResponseSchema = schema.object({

View file

@ -31,7 +31,8 @@ import { getRuleExecutionKPIRoute } from './get_rule_execution_kpi';
import { getRuleStateRoute } from './get_rule_state';
import { healthRoute } from './framework/apis/health';
import { resolveRuleRoute } from './rule/apis/resolve';
import { ruleTypesRoute } from './rule/apis/list_types/rule_types';
import { getRuleTypesRoute } from './rule/apis/list_types/external/get_rule_types_route';
import { getRuleTypesInternalRoute } from './rule/apis/list_types/internal/get_rule_types_internal_route';
import { muteAllRuleRoute } from './rule/apis/mute_all/mute_all_rule';
import { muteAlertRoute } from './rule/apis/mute_alert/mute_alert';
import { unmuteAllRuleRoute } from './rule/apis/unmute_all';
@ -78,6 +79,7 @@ import { findGapsRoute } from './gaps/apis/find/find_gaps_route';
import { fillGapByIdRoute } from './gaps/apis/fill/fill_gap_by_id_route';
import { getRuleIdsWithGapsRoute } from './gaps/apis/get_rule_ids_with_gaps/get_rule_ids_with_gaps_route';
import { getGapsSummaryByRuleIdsRoute } from './gaps/apis/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids_route';
export interface RouteOptions {
router: IRouter<AlertingRequestHandlerContext>;
licenseState: ILicenseState;
@ -118,7 +120,8 @@ export function defineRoutes(opts: RouteOptions) {
getRuleExecutionLogRoute(router, licenseState);
getRuleExecutionKPIRoute(router, licenseState);
getRuleStateRoute(router, licenseState);
ruleTypesRoute(router, licenseState);
getRuleTypesRoute(router, licenseState);
getRuleTypesInternalRoute(router, licenseState);
muteAllRuleRoute(router, licenseState, usageCounter);
unmuteAllRuleRoute(router, licenseState);
updateRuleApiKeyRoute(router, licenseState);

View file

@ -5,19 +5,19 @@
* 2.0.
*/
import { ruleTypesRoute } from './rule_types';
import { getRuleTypesRoute } from './get_rule_types_route';
import { httpServiceMock } from '@kbn/core/server/mocks';
import { licenseStateMock } from '../../../../lib/license_state.mock';
import { verifyApiAccess } from '../../../../lib/license_api_access';
import { mockHandlerArguments } from '../../../_mock_handler_arguments';
import { rulesClientMock } from '../../../../rules_client.mock';
import { RecoveredActionGroup } from '../../../../../common';
import type { RegistryAlertTypeWithAuth } from '../../../../authorization';
import type { AsApiContract } from '../../../lib';
import { licenseStateMock } from '../../../../../lib/license_state.mock';
import { verifyApiAccess } from '../../../../../lib/license_api_access';
import { mockHandlerArguments } from '../../../../_mock_handler_arguments';
import { rulesClientMock } from '../../../../../rules_client.mock';
import { RecoveredActionGroup } from '../../../../../../common';
import type { RegistryAlertTypeWithAuth } from '../../../../../authorization';
import type { AsApiContract } from '../../../../lib';
const rulesClient = rulesClientMock.create();
jest.mock('../../../../lib/license_api_access', () => ({
jest.mock('../../../../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
@ -30,7 +30,7 @@ describe('ruleTypesRoute', () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
ruleTypesRoute(router, licenseState);
getRuleTypesRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
@ -150,7 +150,7 @@ describe('ruleTypesRoute', () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
ruleTypesRoute(router, licenseState);
getRuleTypesRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
@ -208,7 +208,7 @@ describe('ruleTypesRoute', () => {
throw new Error('OMG');
});
ruleTypesRoute(router, licenseState);
getRuleTypesRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];

View file

@ -6,16 +6,16 @@
*/
import type { IRouter } from '@kbn/core/server';
import type { TypesRulesResponseBodyV1 } from '../../../../../common/routes/rule/apis/list_types';
import { typesRulesResponseSchemaV1 } from '../../../../../common/routes/rule/apis/list_types';
import type { ILicenseState } from '../../../../lib';
import { verifyAccessAndContext } from '../../../lib';
import type { AlertingRequestHandlerContext } from '../../../../types';
import { BASE_ALERTING_API_PATH } from '../../../../types';
import type { TypesRulesResponseBodyV1 } from '../../../../../../common/routes/rule/apis/list_types/external';
import { typesRulesResponseSchemaV1 } from '../../../../../../common/routes/rule/apis/list_types/external';
import type { ILicenseState } from '../../../../../lib';
import { verifyAccessAndContext } from '../../../../lib';
import type { AlertingRequestHandlerContext } from '../../../../../types';
import { BASE_ALERTING_API_PATH } from '../../../../../types';
import { transformRuleTypesResponseV1 } from './transforms';
import { DEFAULT_ALERTING_ROUTE_SECURITY } from '../../../constants';
import { DEFAULT_ALERTING_ROUTE_SECURITY } from '../../../../constants';
export const ruleTypesRoute = (
export const getRuleTypesRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { getRuleTypesRoute } from './get_rule_types_route';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './v1';

View file

@ -6,8 +6,8 @@
*/
import { isBoolean } from 'lodash/fp';
import type { RegistryAlertTypeWithAuth } from '../../../../../../authorization';
import type { TypesRulesResponseBodyV1 } from '../../../../../../../common/routes/rule/apis/list_types';
import type { RegistryAlertTypeWithAuth } from '../../../../../../../authorization';
import type { TypesRulesResponseBodyV1 } from '../../../../../../../../common/routes/rule/apis/list_types/external';
export const transformRuleTypesResponse = (
ruleTypes: RegistryAlertTypeWithAuth[]

View file

@ -0,0 +1,262 @@
/*
* 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 { getRuleTypesInternalRoute } from './get_rule_types_internal_route';
import { httpServiceMock } from '@kbn/core/server/mocks';
import { licenseStateMock } from '../../../../../lib/license_state.mock';
import { verifyApiAccess } from '../../../../../lib/license_api_access';
import { mockHandlerArguments } from '../../../../_mock_handler_arguments';
import { rulesClientMock } from '../../../../../rules_client.mock';
import { RecoveredActionGroup } from '../../../../../../common';
import type { RegistryAlertTypeWithAuth } from '../../../../../authorization';
import type { AsApiContract } from '../../../../lib';
const rulesClient = rulesClientMock.create();
jest.mock('../../../../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
});
describe('internalRuleTypesRoute', () => {
it('lists rule types with proper parameters', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getRuleTypesInternalRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/_rule_types"`);
const listTypes = [
{
id: '1',
name: 'name',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
ruleTaskTimeout: '10m',
recoveryActionGroup: RecoveredActionGroup,
authorizedConsumers: {},
actionVariables: {
context: [],
state: [],
},
category: 'test',
producer: 'test',
solution: 'stack',
enabledInLicense: true,
defaultScheduleInterval: '10m',
doesSetRecoveryContext: false,
hasAlertsMappings: true,
hasFieldsForAAD: false,
validLegacyConsumers: [],
} as RegistryAlertTypeWithAuth,
];
const expectedResult: Array<
AsApiContract<Omit<RegistryAlertTypeWithAuth, 'validLegacyConsumers'>>
> = [
{
id: '1',
name: 'name',
action_groups: [
{
id: 'default',
name: 'Default',
},
],
default_action_group_id: 'default',
default_schedule_interval: '10m',
does_set_recovery_context: false,
minimum_license_required: 'basic',
is_exportable: true,
rule_task_timeout: '10m',
recovery_action_group: RecoveredActionGroup,
authorized_consumers: {},
action_variables: {
context: [],
state: [],
},
category: 'test',
producer: 'test',
solution: 'stack',
enabled_in_license: true,
has_alerts_mappings: true,
has_fields_for_a_a_d: false,
},
];
rulesClient.listRuleTypes.mockResolvedValueOnce(listTypes);
const [context, req, res] = mockHandlerArguments({ rulesClient }, {}, ['ok']);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Array [
Object {
"action_groups": Array [
Object {
"id": "default",
"name": "Default",
},
],
"action_variables": Object {
"context": Array [],
"state": Array [],
},
"authorized_consumers": Object {},
"category": "test",
"default_action_group_id": "default",
"default_schedule_interval": "10m",
"does_set_recovery_context": false,
"enabled_in_license": true,
"has_alerts_mappings": true,
"has_fields_for_a_a_d": false,
"id": "1",
"is_exportable": true,
"minimum_license_required": "basic",
"name": "name",
"producer": "test",
"recovery_action_group": Object {
"id": "recovered",
"name": "Recovered",
},
"rule_task_timeout": "10m",
"solution": "stack",
},
],
}
`);
expect(rulesClient.listRuleTypes).toHaveBeenCalledTimes(1);
expect(res.ok).toHaveBeenCalledWith({
body: expectedResult,
});
});
it('ensures the license allows listing rule types', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getRuleTypesInternalRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/_rule_types"`);
const listTypes = [
{
id: '1',
name: 'name',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
authorizedConsumers: {},
actionVariables: {
context: [],
state: [],
},
category: 'test',
producer: 'alerts',
solution: 'stack',
enabledInLicense: true,
hasAlertsMappings: false,
hasFieldsForAAD: false,
validLegacyConsumers: [],
} as RegistryAlertTypeWithAuth,
];
rulesClient.listRuleTypes.mockResolvedValueOnce(listTypes);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
params: { id: '1' },
},
['ok']
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
it('ensures the license check prevents listing rule types', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('OMG');
});
getRuleTypesInternalRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/_rule_types"`);
const listTypes = [
{
id: '1',
name: 'name',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
authorizedConsumers: {},
actionVariables: {
context: [],
state: [],
},
category: 'test',
producer: 'alerts',
solution: 'stack',
enabledInLicense: true,
hasAlertsMappings: false,
hasFieldsForAAD: false,
validLegacyConsumers: [],
} as RegistryAlertTypeWithAuth,
];
rulesClient.listRuleTypes.mockResolvedValueOnce(listTypes);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
params: { id: '1' },
},
['ok']
);
await expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
});

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 type { IRouter } from '@kbn/core/server';
import type { GetRuleTypesInternalResponseBodyV1 } from '../../../../../../common/routes/rule/apis/list_types/internal';
import { getRuleTypesInternalResponseSchemaV1 } from '../../../../../../common/routes/rule/apis/list_types/internal';
import type { ILicenseState } from '../../../../../lib';
import { verifyAccessAndContext } from '../../../../lib';
import type { AlertingRequestHandlerContext } from '../../../../../types';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../../../types';
import { transformRuleTypesInternalResponseV1 } from './transforms';
import { DEFAULT_ALERTING_ROUTE_SECURITY } from '../../../../constants';
export const getRuleTypesInternalRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.get(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/_rule_types`,
security: DEFAULT_ALERTING_ROUTE_SECURITY,
options: {
access: 'internal',
},
validate: {
request: {},
response: {
200: {
body: () => getRuleTypesInternalResponseSchemaV1,
description: 'Indicates a successful call.',
},
401: {
description: 'Authorization information is missing or invalid.',
},
},
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesClient = await (await context.alerting).getRulesClient();
const ruleTypes = await rulesClient.listRuleTypes();
const responseBody: GetRuleTypesInternalResponseBodyV1 =
transformRuleTypesInternalResponseV1(ruleTypes);
return res.ok({
body: responseBody,
});
})
)
);
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { getRuleTypesInternalRoute } from './get_rule_types_internal_route';

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { transformRuleTypesInternalResponse } from './transform_rule_types_internal_response/latest';
export { transformRuleTypesInternalResponse as transformRuleTypesInternalResponseV1 } from './transform_rule_types_internal_response/v1';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './v1';

View file

@ -0,0 +1,43 @@
/*
* 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 { isBoolean } from 'lodash/fp';
import type { RegistryAlertTypeWithAuth } from '../../../../../../../authorization';
import type { GetRuleTypesInternalResponseBodyV1 } from '../../../../../../../../common/routes/rule/apis/list_types/internal';
export const transformRuleTypesInternalResponse = (
ruleTypes: RegistryAlertTypeWithAuth[]
): GetRuleTypesInternalResponseBodyV1 => {
return ruleTypes.map((ruleType: RegistryAlertTypeWithAuth) => {
return {
...(ruleType.actionGroups ? { action_groups: ruleType.actionGroups } : {}),
...(ruleType.actionVariables ? { action_variables: ruleType.actionVariables } : {}),
...(ruleType.alerts ? { alerts: ruleType.alerts } : {}),
authorized_consumers: ruleType.authorizedConsumers,
category: ruleType.category,
default_action_group_id: ruleType.defaultActionGroupId,
...(ruleType.defaultScheduleInterval
? { default_schedule_interval: ruleType.defaultScheduleInterval }
: {}),
...(isBoolean(ruleType.doesSetRecoveryContext)
? { does_set_recovery_context: ruleType.doesSetRecoveryContext }
: {}),
enabled_in_license: ruleType.enabledInLicense,
...(ruleType.fieldsForAAD ? { fieldsForAAD: ruleType.fieldsForAAD } : {}),
has_alerts_mappings: ruleType.hasAlertsMappings,
has_fields_for_a_a_d: ruleType.hasFieldsForAAD,
id: ruleType.id,
is_exportable: ruleType.isExportable,
minimum_license_required: ruleType.minimumLicenseRequired,
name: ruleType.name,
producer: ruleType.producer,
solution: ruleType.solution,
recovery_action_group: ruleType.recoveryActionGroup,
...(ruleType.ruleTaskTimeout ? { rule_task_timeout: ruleType.ruleTaskTimeout } : {}),
};
});
};

View file

@ -11,23 +11,20 @@ import { verifyApiAccess } from '../../../../lib/license_api_access';
import { mockHandlerArguments } from '../../../_mock_handler_arguments';
import { rulesClientMock } from '../../../../rules_client.mock';
import { getRuleTagsRoute } from './get_rule_tags';
import type { KibanaRequest } from '@kbn/core-http-server';
const rulesClient = rulesClientMock.create();
rulesClient.getTags.mockResolvedValue({
data: ['a', 'b', 'c'],
page: 1,
perPage: 10,
total: 3,
});
jest.mock('../../../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
rulesClient.getTags.mockResolvedValueOnce({
data: ['a', 'b', 'c'],
page: 1,
perPage: 10,
total: 3,
});
});
describe('getRuleTagsRoute', () => {
it('gets rule tags with proper parameters', async () => {
const licenseState = licenseStateMock.create();
@ -51,7 +48,6 @@ describe('getRuleTagsRoute', () => {
},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
@ -66,6 +62,7 @@ describe('getRuleTagsRoute', () => {
},
}
`);
expect(res.ok).toHaveBeenCalledWith({
body: {
data: ['a', 'b', 'c'],
@ -76,6 +73,70 @@ describe('getRuleTagsRoute', () => {
});
});
it('correctly parses query params', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getRuleTagsRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
// No rule_type_ids
const query = {
search: 'test',
per_page: 10,
page: 1,
};
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
query,
},
['ok']
);
await handler(context, req, res);
expect(rulesClient.getTags).toHaveBeenCalledWith({
search: 'test',
perPage: 10,
page: 1,
});
// Singe rule_type_ids value
await handler(
context,
{
query: {
...query,
rule_type_ids: 'rule_type_1',
},
} as KibanaRequest,
res
);
expect(rulesClient.getTags).toHaveBeenCalledWith({
search: 'test',
perPage: 10,
page: 1,
ruleTypeIds: ['rule_type_1'],
});
// Array rule_type_ids
await handler(
context,
{
query: {
...query,
rule_type_ids: ['rule_type_1', 'rule_type_2'],
},
} as KibanaRequest,
res
);
expect(rulesClient.getTags).toHaveBeenCalledWith({
search: 'test',
perPage: 10,
page: 1,
ruleTypeIds: ['rule_type_1', 'rule_type_2'],
});
});
it('ensures the license allows getting rule tags', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();

View file

@ -5,17 +5,17 @@
* 2.0.
*/
import type { RewriteRequestCase } from '@kbn/actions-plugin/common';
import type { RuleTagsRequestQueryV1 } from '../../../../../../../common/routes/rule/apis/tags';
import type { RuleTagsParams } from '../../../../../../application/rule/methods/tags';
export const transformRuleTagsQueryRequest: RewriteRequestCase<RuleTagsParams> = ({
export const transformRuleTagsQueryRequest = ({
per_page: perPage,
page,
search,
rule_type_ids: ruleTypeIds,
}) => ({
}: RuleTagsRequestQueryV1): RuleTagsParams => ({
page,
search,
perPage,
ruleTypeIds,
...(ruleTypeIds ? { ruleTypeIds: Array.isArray(ruleTypeIds) ? ruleTypeIds : [ruleTypeIds] } : {}),
});

View file

@ -27,7 +27,7 @@ import type { SharePluginStart } from '@kbn/share-plugin/server';
import type { DefaultAlert, FieldMap } from '@kbn/alerts-as-data-utils';
import type { Alert } from '@kbn/alerts-as-data-utils';
import type { ActionsApiRequestHandlerContext } from '@kbn/actions-plugin/server';
import type { AlertsHealth } from '@kbn/alerting-types';
import type { AlertsHealth, RuleTypeSolution } from '@kbn/alerting-types';
import type { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry';
import type { AlertingServerSetup, AlertingServerStart } from './plugin';
import type { RulesClient } from './rules_client';
@ -265,8 +265,6 @@ export interface IRuleTypeAlerts<AlertData extends RuleAlertData = never> {
formatAlert?: FormatAlert<AlertData>;
}
export type RuleTypeSolution = 'observability' | 'security' | 'stack';
export interface RuleType<
Params extends RuleTypeParams = never,
ExtractedParams extends RuleTypeParams = never,

View file

@ -6,7 +6,7 @@
*/
import { coreMock } from '@kbn/core/public/mocks';
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
import { fetchRuleTypes } from '@kbn/alerts-ui-shared/src/common/apis/fetch_rule_types';
import { getRuleTypes } from '@kbn/response-ops-rules-apis/apis/get_rule_types';
import { getAddAlertsTableAction } from './add_alerts_table_action';
import { ALERTS_FEATURE_ID } from '@kbn/alerts-ui-shared/src/common/constants';
@ -15,15 +15,15 @@ import type { RuleType } from '@kbn/triggers-actions-ui-types';
const core = coreMock.createStart();
const mockPresentationContainer = getMockPresentationContainer();
jest.mock('@kbn/alerts-ui-shared/src/common/apis/fetch_rule_types');
const mockFetchRuleTypes = jest.mocked(fetchRuleTypes);
jest.mock('@kbn/response-ops-rules-apis/apis/get_rule_types');
const mockGetRuleTypes = jest.mocked(getRuleTypes);
describe('getAddAlertsTableAction', () => {
it('should be compatible only when the user has access to at least one rule type', async () => {
const ruleTypes = [
{ authorizedConsumers: { [ALERTS_FEATURE_ID]: { all: true } } },
] as unknown as Array<RuleType<string, string>>;
mockFetchRuleTypes.mockResolvedValue(ruleTypes);
mockGetRuleTypes.mockResolvedValue(ruleTypes);
const action = getAddAlertsTableAction({ http: core.http });
@ -36,7 +36,7 @@ describe('getAddAlertsTableAction', () => {
it("should not be compatible when the user doesn't have access to any rule type", async () => {
const ruleTypes = [] as unknown as Array<RuleType<string, string>>;
mockFetchRuleTypes.mockResolvedValue(ruleTypes);
mockGetRuleTypes.mockResolvedValue(ruleTypes);
const action = getAddAlertsTableAction({ http: core.http });

View file

@ -6,7 +6,7 @@
*/
import { ADD_PANEL_VISUALIZATION_GROUP } from '@kbn/embeddable-plugin/public';
import { fetchRuleTypes } from '@kbn/alerts-ui-shared/src/common/apis/fetch_rule_types';
import { getRuleTypes } from '@kbn/response-ops-rules-apis/apis/get_rule_types';
import { ALERTS_FEATURE_ID } from '@kbn/alerts-ui-shared/src/common/constants';
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
@ -18,7 +18,7 @@ import { ADD_ALERTS_TABLE_ACTION_ID, EMBEDDABLE_ALERTS_TABLE_ID } from '../const
const checkRuleTypesPermissions = async (http: CoreStart['http']) => {
try {
const ruleTypes = await fetchRuleTypes({ http });
const ruleTypes = await getRuleTypes({ http });
if (!ruleTypes.length) {
// If no rule types we should not show the action.
return false;

View file

@ -31,5 +31,6 @@
"@kbn/core-ui-settings-browser",
"@kbn/rule-data-utils",
"@kbn/triggers-actions-ui-types",
"@kbn/response-ops-rules-apis",
]
}

View file

@ -33,13 +33,15 @@ jest.mock('./context/health_context', () => ({
HealthContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
jest.mock('./hooks/use_load_rule_types_query', () => ({
useLoadRuleTypesQuery: jest.fn().mockReturnValue({
jest.mock('@kbn/alerts-ui-shared/src/common/hooks/use_get_rule_types_permissions', () => ({
useGetRuleTypesPermissions: jest.fn().mockReturnValue({
authorizedToReadAnyRules: true,
}),
}));
const { useLoadRuleTypesQuery } = jest.requireMock('./hooks/use_load_rule_types_query');
const { useGetRuleTypesPermissions } = jest.requireMock(
'@kbn/alerts-ui-shared/src/common/hooks/use_get_rule_types_permissions'
);
const queryClient = new QueryClient();
@ -47,7 +49,7 @@ describe('home', () => {
beforeEach(() => {
(hasShowActionsCapability as jest.Mock).mockClear();
(getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => false);
useLoadRuleTypesQuery.mockClear();
useGetRuleTypesPermissions.mockClear();
});
it('renders rule list components', async () => {
@ -111,7 +113,7 @@ describe('home', () => {
});
it('hides the logs tab if the read rules privilege is missing', async () => {
useLoadRuleTypesQuery.mockReturnValue({
useGetRuleTypesPermissions.mockReturnValue({
authorizedToReadAnyRules: false,
});
const props: RouteComponentProps<MatchParams> = {

View file

@ -12,6 +12,7 @@ import { Routes, Route } from '@kbn/shared-ux-router';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiSpacer, EuiPageTemplate } from '@elastic/eui';
import { useGetRuleTypesPermissions } from '@kbn/alerts-ui-shared/src/common/hooks/use_get_rule_types_permissions';
import { Section, routeToRules, routeToLogs } from './constants';
import { getAlertingSectionBreadcrumb } from './lib/breadcrumb';
import { getCurrentDocTitle } from './lib/doc_title';
@ -20,7 +21,6 @@ import { HealthCheck } from './components/health_check';
import { HealthContextProvider } from './context/health_context';
import { useKibana } from '../common/lib/kibana';
import { suspendedComponentWithProps } from './lib/suspended_component_with_props';
import { useLoadRuleTypesQuery } from './hooks/use_load_rule_types_query';
const RulesList = lazy(() => import('./sections/rules_list/components/rules_list'));
const LogsList = lazy(
@ -38,8 +38,17 @@ export const TriggersActionsUIHome: React.FunctionComponent<RouteComponentProps<
history,
}) => {
const [headerActions, setHeaderActions] = useState<React.ReactNode[] | undefined>();
const { chrome, setBreadcrumbs } = useKibana().services;
const { authorizedToReadAnyRules } = useLoadRuleTypesQuery({ filteredRuleTypes: [] });
const {
chrome,
setBreadcrumbs,
http,
notifications: { toasts },
} = useKibana().services;
const { authorizedToReadAnyRules } = useGetRuleTypesPermissions({
http,
toasts,
filteredRuleTypes: [],
});
const tabs: Array<{
id: Section;

View file

@ -6,4 +6,3 @@
*/
export { useSubAction } from './use_sub_action';
export { useLoadRuleTypesQuery } from './use_load_rule_types_query';

View file

@ -1,95 +0,0 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { useQuery } from '@tanstack/react-query';
import { ALERTING_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { useMemo } from 'react';
import { loadRuleTypes } from '../lib/rule_api/rule_types';
import { useKibana } from '../../common/lib/kibana';
import type { RuleType, RuleTypeIndex } from '../../types';
interface UseLoadRuleTypesQueryProps {
filteredRuleTypes: string[];
enabled?: boolean;
}
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 = ({
filteredRuleTypes,
enabled = true,
}: UseLoadRuleTypesQueryProps) => {
const {
http,
notifications: { toasts },
} = useKibana().services;
const queryFn = () => {
return loadRuleTypes({ http });
};
const onErrorFn = () => {
toasts.addDanger(
i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTypesMessage', {
defaultMessage: 'Unable to load rule types',
})
);
};
const { data, isSuccess, isFetching, isInitialLoading, isLoading, error } = useQuery({
queryKey: ['loadRuleTypes'],
queryFn,
onError: onErrorFn,
refetchOnWindowFocus: false,
// Leveraging TanStack Query's caching system to avoid duplicated requests as
// other state-sharing solutions turned out to be overly complex and less readable
staleTime: 60 * 1000,
enabled,
});
const filteredIndex = useMemo(
() => (data ? getFilteredIndex(data, filteredRuleTypes) : new Map<string, RuleType>()),
[data, filteredRuleTypes]
);
const hasAnyAuthorizedRuleType = filteredIndex.size > 0;
const authorizedRuleTypes = useMemo(() => [...filteredIndex.values()], [filteredIndex]);
const authorizedToCreateAnyRules = authorizedRuleTypes.some(
(ruleType) => ruleType.authorizedConsumers[ALERTING_FEATURE_ID]?.all
);
const authorizedToReadAnyRules =
authorizedToCreateAnyRules ||
authorizedRuleTypes.some((ruleType) => ruleType.authorizedConsumers[ALERTING_FEATURE_ID]?.read);
return {
ruleTypesState: {
initialLoad: isLoading || isInitialLoading,
isLoading: isLoading || isFetching,
data: filteredIndex,
error,
},
hasAnyAuthorizedRuleType,
authorizedRuleTypes,
authorizedToReadAnyRules,
authorizedToCreateAnyRules,
isSuccess,
};
};

View file

@ -34,6 +34,7 @@ describe('checkRuleTypeEnabled', () => {
minimumLicenseRequired: 'basic',
enabledInLicense: true,
category: 'my-category',
isExportable: true,
};
expect(checkRuleTypeEnabled(alertType)).toMatchInlineSnapshot(`
Object {
@ -59,6 +60,7 @@ describe('checkRuleTypeEnabled', () => {
minimumLicenseRequired: 'gold',
enabledInLicense: false,
category: 'my-category',
isExportable: true,
};
expect(checkRuleTypeEnabled(alertType)).toMatchInlineSnapshot(`
Object {

View file

@ -64,6 +64,7 @@ function mockRuleType(overwrites: Partial<RuleType> = {}): RuleType {
minimumLicenseRequired: 'basic',
enabledInLicense: true,
category: 'my-category',
isExportable: true,
...overwrites,
};
}

View file

@ -6,7 +6,7 @@
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { loadRuleAggregations, loadRuleTags } from './aggregate';
import { loadRuleAggregations } from './aggregate';
const http = httpServiceMock.createStartContract();
@ -287,41 +287,4 @@ describe('loadRuleAggregations', () => {
]
`);
});
test('loadRuleTags should call the getTags API', async () => {
const resolvedValue = {
data: ['a', 'b', 'c'],
total: 3,
page: 2,
per_page: 30,
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadRuleTags({
http,
search: 'test',
page: 2,
perPage: 30,
});
expect(result).toEqual({
data: ['a', 'b', 'c'],
page: 2,
perPage: 30,
total: 3,
});
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/internal/alerting/rules/_tags",
Object {
"query": Object {
"page": 2,
"per_page": 30,
"search": "test",
},
},
]
`);
});
});

View file

@ -4,36 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AsApiContract } from '@kbn/actions-plugin/common';
import type { AggregateRulesResponseBody } from '@kbn/alerting-plugin/common/routes/rule/apis/aggregate';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
import { mapFiltersToKql } from './map_filters_to_kql';
import type {
LoadRuleAggregationsProps,
LoadRuleTagsProps,
GetRuleTagsResponse,
AggregateRulesResponse,
} from './aggregate_helpers';
import { rewriteBodyRes, rewriteTagsBodyRes } from './aggregate_helpers';
export async function loadRuleTags({
http,
search,
perPage,
page,
}: LoadRuleTagsProps): Promise<GetRuleTagsResponse> {
const res = await http.get<AsApiContract<GetRuleTagsResponse>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/_tags`,
{
query: {
search,
per_page: perPage,
page,
},
}
);
return rewriteTagsBodyRes(res);
}
import type { LoadRuleAggregationsProps, AggregateRulesResponse } from './aggregate_helpers';
import { rewriteBodyRes } from './aggregate_helpers';
export async function loadRuleAggregations({
http,

View file

@ -6,7 +6,6 @@
*/
import type { HttpSetup } from '@kbn/core/public';
import type { RewriteRequestCase } from '@kbn/actions-plugin/common';
import type { AggregateRulesResponseBody } from '@kbn/alerting-plugin/common/routes/rule/apis/aggregate';
import type { RuleStatus } from '../../../types';
@ -43,21 +42,6 @@ export const rewriteBodyRes = ({
ruleTags,
});
export interface GetRuleTagsResponse {
total: number;
page: number;
perPage: number;
data: string[];
}
export const rewriteTagsBodyRes: RewriteRequestCase<GetRuleTagsResponse> = ({
per_page: perPage,
...rest
}) => ({
perPage,
...rest,
});
export interface LoadRuleAggregationsProps {
http: HttpSetup;
searchText?: string;
@ -69,10 +53,3 @@ export interface LoadRuleAggregationsProps {
ruleTypeIds?: string[];
consumers?: string[];
}
export interface LoadRuleTagsProps {
http: HttpSetup;
search?: string;
perPage?: number;
page: number;
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
export type { LoadRuleAggregationsProps, LoadRuleTagsProps } from './aggregate_helpers';
export type { LoadRuleAggregationsProps } from './aggregate_helpers';
export type { LoadRulesProps } from './rules_helpers';
export type {
LoadExecutionLogAggregationsProps,

View file

@ -1,46 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RuleType } from '../../../types';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { loadRuleTypes } from './rule_types';
import { ALERTING_FEATURE_ID } from '@kbn/alerting-plugin/common';
const http = httpServiceMock.createStartContract();
describe('loadRuleTypes', () => {
test('should call get alert types API', async () => {
const resolvedValue: RuleType[] = [
{
id: 'test',
name: 'Test',
actionVariables: {
context: [{ name: 'var1', description: 'val1' }],
state: [{ name: 'var2', description: 'val2' }],
params: [{ name: 'var3', description: 'val3' }],
},
producer: ALERTING_FEATURE_ID,
actionGroups: [{ id: 'default', name: 'Default' }],
recoveryActionGroup: { id: 'recovered', name: 'Recovered' },
defaultActionGroupId: 'default',
authorizedConsumers: {},
minimumLicenseRequired: 'basic',
enabledInLicense: true,
category: 'my-category',
},
];
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadRuleTypes({ http });
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alerting/rule_types",
]
`);
});
});

View file

@ -1,51 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { HttpSetup } from '@kbn/core/public';
import type { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common';
import type { RuleType } from '../../../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 loadRuleTypes({ http }: { http: HttpSetup }): Promise<RuleType[]> {
const res = await http.get<Array<AsApiContract<RuleType<string, string>>>>(
`${BASE_ALERTING_API_PATH}/rule_types`
);
return rewriteResponseRes(res);
}

Some files were not shown because too many files have changed in this diff Show more