diff --git a/.buildkite/ftr_base_serverless_configs.yml b/.buildkite/ftr_base_serverless_configs.yml index 8d26f692f24d3..4258a6efb3033 100644 --- a/.buildkite/ftr_base_serverless_configs.yml +++ b/.buildkite/ftr_base_serverless_configs.yml @@ -12,6 +12,7 @@ disabled: enabled: # Serverless deployment-agnostic configs to run platform api-integration tests - x-pack/platform/test/api_integration_deployment_agnostic/configs/serverless/oblt.serverless.config.ts + - x-pack/platform/test/api_integration_deployment_agnostic/configs/serverless/oblt.alerting_v2.serverless.config.ts - x-pack/platform/test/api_integration_deployment_agnostic/configs/serverless/search.serverless.config.ts - x-pack/platform/test/api_integration_deployment_agnostic/configs/serverless/security.serverless.config.ts - x-pack/platform/test/api_integration_deployment_agnostic/configs/serverless/oblt.logs_essentials.serverless.config.ts diff --git a/.buildkite/ftr_platform_stateful_configs.yml b/.buildkite/ftr_platform_stateful_configs.yml index b618397d4e51b..735140d7d9900 100644 --- a/.buildkite/ftr_platform_stateful_configs.yml +++ b/.buildkite/ftr_platform_stateful_configs.yml @@ -492,3 +492,4 @@ enabled: - x-pack/platform/test/saved_object_api_integration/user_profiles/config.ts # stateful config files that run deployment-agnostic tests - x-pack/platform/test/api_integration_deployment_agnostic/configs/stateful/platform.stateful.config.ts + - x-pack/platform/test/api_integration_deployment_agnostic/configs/stateful/platform.alerting_v2.stateful.config.ts diff --git a/.buildkite/scout_ci_config.yml b/.buildkite/scout_ci_config.yml index a1a5f93c1be92..5ebbc08cec519 100644 --- a/.buildkite/scout_ci_config.yml +++ b/.buildkite/scout_ci_config.yml @@ -3,6 +3,7 @@ plugins: enabled: - advanced_settings - alerting + - alerting_v2 - apm - automatic_import_v2 - banners diff --git a/.buildkite/scripts/steps/security/third_party_packages.txt b/.buildkite/scripts/steps/security/third_party_packages.txt index b3d003a91d7dc..8943cb9dd0e21 100644 --- a/.buildkite/scripts/steps/security/third_party_packages.txt +++ b/.buildkite/scripts/steps/security/third_party_packages.txt @@ -31,6 +31,8 @@ yaml-language-server @opentelemetry/exporter-metrics-otlp-proto simple-statistics monaco-promql +json-stable-stringify +apache-arrow oxlint react-element-to-jsx-string @swc/core diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d5d2fac335a7c..d7c6db5b1e4f6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1037,6 +1037,9 @@ x-pack/platform/packages/shared/ml/random_sampler_utils @elastic/ml-ui x-pack/platform/packages/shared/ml/response_stream @elastic/ml-ui x-pack/platform/packages/shared/ml/runtime_field_utils @elastic/ml-ui x-pack/platform/packages/shared/ml/trained_models_utils @elastic/ml-ui +x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui @elastic/response-ops +x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form @elastic/rna-project-team +x-pack/platform/packages/shared/response-ops/alerting-v2-schemas @elastic/rna-project-team x-pack/platform/packages/shared/response-ops/alerts-apis @elastic/response-ops x-pack/platform/packages/shared/response-ops/alerts-delete @elastic/response-ops x-pack/platform/packages/shared/response-ops/alerts-fields-browser @elastic/response-ops @@ -1050,6 +1053,7 @@ x-pack/platform/packages/shared/response-ops/rule_form @elastic/response-ops x-pack/platform/packages/shared/response-ops/rule_params @elastic/response-ops x-pack/platform/packages/shared/response-ops/rules-apis @elastic/response-ops x-pack/platform/packages/shared/response-ops/scheduling-types @elastic/response-ops +x-pack/platform/packages/shared/response-ops/yaml-rule-editor @elastic/response-ops x-pack/platform/packages/shared/security/api_key_management @elastic/kibana-security x-pack/platform/packages/shared/security/form_components @elastic/kibana-security x-pack/platform/packages/shared/security/plugin_types_common @elastic/kibana-security @@ -1104,6 +1108,7 @@ x-pack/platform/plugins/shared/ai_infra/llm_tasks @elastic/appex-ai-infra x-pack/platform/plugins/shared/ai_infra/product_doc_base @elastic/appex-ai-infra x-pack/platform/plugins/shared/aiops @elastic/ml-ui x-pack/platform/plugins/shared/alerting @elastic/response-ops +x-pack/platform/plugins/shared/alerting_v2 @elastic/rna-project-team x-pack/platform/plugins/shared/anonymization @elastic/workchat-eng @elastic/security-generative-ai @elastic/security-threat-hunting x-pack/platform/plugins/shared/apm_sources_access @elastic/obs-presentation-team x-pack/platform/plugins/shared/automatic_import @elastic/integration-experience @@ -1857,6 +1862,9 @@ x-pack/platform/plugins/shared/streams_app/public/components/data_management/str /.buildkite/pipeline-resource-definitions/kibana-streams-performance-weekly.yml @elastic/obs-sig-events-team @elastic/obs-onboarding-team +# Alerting alerting_v2 +/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2 @elastic/rna-project-team + ### END Observability Plugins # Presentation diff --git a/docs/extend/plugin-list.md b/docs/extend/plugin-list.md index 3ee2d7b053edf..93433e95a6063 100644 --- a/docs/extend/plugin-list.md +++ b/docs/extend/plugin-list.md @@ -116,6 +116,7 @@ mapped_pages: | [agentBuilderPlatform](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/agent_builder_platform/README.md) | Contains the platform-owned agent builder entities | | [aiops](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/aiops/README.md) | The plugin provides APIs and components for AIOps features, including the “Log rate analysis” UI, maintained by the ML team. | | [alerting](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/alerting/README.md) | The Kibana Alerting plugin provides a common place to set up rules. You can: | +| [alertingVTwo](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/alerting_v2/README.md) | Plugin for the v2 alerting engine. | | [anonymization](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/anonymization/README.md) | Home of the platform-owned anonymization policy service used by inference-related workflows. | | [apm](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/apm/readme.md) | This plugin provides access to App Monitoring features provided by Elastic. It allows you to monitor your software services and applications in real-time; visualize detailed performance information on your services, identify and analyze errors, and monitor host-level and APM agent-specific metrics like JVM and Go runtime metrics. | | [apmDataAccess](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/apm_data_access) | WARNING: Missing or empty README. | diff --git a/package.json b/package.json index b1f3f50b961c4..90a8afe2e0dc5 100644 --- a/package.json +++ b/package.json @@ -215,6 +215,10 @@ "@kbn/alerting-rule-utils": "link:x-pack/platform/packages/shared/alerting-rule-utils", "@kbn/alerting-state-types": "link:x-pack/platform/packages/private/kbn-alerting-state-types", "@kbn/alerting-types": "link:src/platform/packages/shared/kbn-alerting-types", + "@kbn/alerting-v2-episodes-ui": "link:x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui", + "@kbn/alerting-v2-plugin": "link:x-pack/platform/plugins/shared/alerting_v2", + "@kbn/alerting-v2-rule-form": "link:x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form", + "@kbn/alerting-v2-schemas": "link:x-pack/platform/packages/shared/response-ops/alerting-v2-schemas", "@kbn/alerts-as-data-utils": "link:src/platform/packages/shared/kbn-alerts-as-data-utils", "@kbn/alerts-grouping": "link:x-pack/solutions/observability/packages/kbn-alerts-grouping", "@kbn/alerts-restricted-fixtures-plugin": "link:x-pack/platform/test/alerting_api_integration/common/plugins/alerts_restricted", @@ -1235,6 +1239,7 @@ "@kbn/workflows-ui": "link:src/platform/packages/shared/kbn-workflows-ui", "@kbn/workplace-ai-app": "link:x-pack/solutions/workplaceai/plugins/workplace_ai_app", "@kbn/xstate-utils": "link:src/platform/packages/shared/kbn-xstate-utils", + "@kbn/yaml-rule-editor": "link:x-pack/platform/packages/shared/response-ops/yaml-rule-editor", "@kbn/zod": "link:src/platform/packages/shared/kbn-zod", "@kbn/zod-helpers": "link:src/platform/packages/shared/kbn-zod-helpers", "@langchain/aws": "1.3.1", @@ -1305,6 +1310,7 @@ "ajv": "8.18.0", "ajv-formats": "3.0.1", "antlr4": "4.13.2", + "apache-arrow": "21.1.0", "archiver": "7.0.1", "async": "3.2.6", "aws4": "1.13.2", diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index 9b3c2afd3627f..cac8c62205631 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -90,6 +90,62 @@ "updatedAt", "updatedBy" ], + "alerting_api_key_pending_invalidation": [ + "apiKeyId", + "createdAt", + "uiamApiKey" + ], + "alerting_notification_policy": [ + "auth", + "auth.apiKey", + "auth.createdByUser", + "auth.owner", + "createdAt", + "createdBy", + "createdByUsername", + "description", + "destinations", + "destinations.id", + "destinations.type", + "enabled", + "groupBy", + "name", + "snoozedUntil", + "updatedAt", + "updatedBy", + "updatedByUsername" + ], + "alerting_rule": [ + "createdAt", + "createdBy", + "enabled", + "evaluation", + "evaluation.query", + "evaluation.query.base", + "evaluation.query.condition", + "grouping", + "grouping.fields", + "kind", + "metadata", + "metadata.description", + "metadata.labels", + "metadata.name", + "metadata.owner", + "no_data", + "no_data.behavior", + "no_data.timeframe", + "recovery_policy", + "recovery_policy.query", + "recovery_policy.query.base", + "recovery_policy.query.condition", + "recovery_policy.type", + "schedule", + "schedule.every", + "schedule.lookback", + "time_field", + "updatedAt", + "updatedBy" + ], "alerting_rule_template": [ "description", "name", diff --git a/packages/kbn-check-saved-objects-cli/current_mappings.json b/packages/kbn-check-saved-objects-cli/current_mappings.json index e29364aba8ea8..9750b21ab4d53 100644 --- a/packages/kbn-check-saved-objects-cli/current_mappings.json +++ b/packages/kbn-check-saved-objects-cli/current_mappings.json @@ -301,6 +301,195 @@ } } }, + "alerting_api_key_pending_invalidation": { + "dynamic": false, + "properties": { + "apiKeyId": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, + "uiamApiKey": { + "type": "binary" + } + } + }, + "alerting_notification_policy": { + "dynamic": false, + "properties": { + "auth": { + "properties": { + "apiKey": { + "type": "binary" + }, + "createdByUser": { + "type": "boolean" + }, + "owner": { + "type": "keyword" + } + }, + "type": "object" + }, + "createdAt": { + "type": "date" + }, + "createdBy": { + "type": "keyword" + }, + "createdByUsername": { + "type": "keyword" + }, + "description": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "destinations": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "object" + }, + "enabled": { + "type": "boolean" + }, + "groupBy": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "snoozedUntil": { + "type": "date" + }, + "updatedAt": { + "type": "date" + }, + "updatedBy": { + "type": "keyword" + }, + "updatedByUsername": { + "type": "keyword" + } + } + }, + "alerting_rule": { + "dynamic": false, + "properties": { + "createdAt": { + "type": "date" + }, + "createdBy": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "evaluation": { + "properties": { + "query": { + "properties": { + "base": { + "type": "text" + }, + "condition": { + "type": "text" + } + } + } + } + }, + "grouping": { + "properties": { + "fields": { + "type": "keyword" + } + } + }, + "kind": { + "type": "keyword" + }, + "metadata": { + "properties": { + "description": { + "type": "text" + }, + "labels": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "owner": { + "type": "keyword" + } + } + }, + "no_data": { + "properties": { + "behavior": { + "type": "keyword" + }, + "timeframe": { + "type": "keyword" + } + } + }, + "recovery_policy": { + "properties": { + "query": { + "properties": { + "base": { + "type": "text" + }, + "condition": { + "type": "text" + } + } + }, + "type": { + "type": "keyword" + } + } + }, + "schedule": { + "properties": { + "every": { + "type": "keyword" + }, + "lookback": { + "type": "keyword" + } + } + }, + "time_field": { + "type": "keyword" + }, + "updatedAt": { + "type": "date" + }, + "updatedBy": { + "type": "keyword" + } + } + }, "alerting_rule_template": { "dynamic": false, "properties": { @@ -1492,6 +1681,9 @@ "install_version": { "type": "keyword" }, + "installed_as_dependency": { + "type": "boolean" + }, "installed_es": { "properties": { "deferred": { @@ -1535,9 +1727,6 @@ "enabled": false, "type": "object" }, - "installed_as_dependency": { - "type": "boolean" - }, "name": { "type": "keyword" }, diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index f471343768c3f..f46847dd17616 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -6,7 +6,8 @@ pageLoadAssetSize: aiAssistantManagementSelection: 13590 aiops: 15227 alerting: 22371 - apm: 42431 + alertingVTwo: 415712 + apm: 25363 apmSourcesAccess: 2278 automaticImport: 12162 automaticImportVTwo: 17130 @@ -33,7 +34,7 @@ pageLoadAssetSize: dashboard: 20000 dashboardAgent: 5135 dashboardMarkdown: 6219 - data: 496626 + data: 496967 dataQuality: 11469 datasetQuality: 49315 dataSources: 6748 @@ -45,7 +46,7 @@ pageLoadAssetSize: dataVisualizer: 32778 developerToolbar: 4467 devTools: 8109 - discover: 28241 + discover: 28240 discoverEnhanced: 5509 discoverShared: 2322 elasticAssistant: 338870 diff --git a/renovate.json b/renovate.json index 0df92f0cb7a9d..73f17d29e198f 100644 --- a/renovate.json +++ b/renovate.json @@ -2724,6 +2724,23 @@ "minimumReleaseAge": "14 days", "enabled": true }, + { + "groupName": "alerting v2 dependencies", + "matchDepNames": [ + "apache-arrow" + ], + "reviewers": [ + "team:response-ops" + ], + "matchBaseBranches": [ + "main" + ], + "labels": [ + "Team:ResponseOps" + ], + "enabled": true, + "minimumReleaseAge": "14 days" + }, { "groupName": "react-day-picker", "matchDepNames": ["react-day-picker"], diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 42b7959238176..4e448f853f18a 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -9,6 +9,9 @@ export const storybookAliases = { ai_assistant: 'x-pack/platform/packages/shared/kbn-ai-assistant/.storybook', + alerting_v2: 'x-pack/platform/plugins/shared/alerting_v2/.storybook', + alerting_v2_rule_form: + 'x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/.storybook', apm: 'x-pack/solutions/observability/plugins/apm/.storybook', apm_ui_shared: 'src/platform/packages/shared/kbn-apm-ui-shared/.storybook', cases: 'src/platform/packages/shared/kbn-cases-components/.storybook', diff --git a/src/platform/packages/private/kbn-ui-shared-deps-npm/version_dependencies.txt b/src/platform/packages/private/kbn-ui-shared-deps-npm/version_dependencies.txt index 5c446cc8dc7a8..ec6d8005f9741 100644 --- a/src/platform/packages/private/kbn-ui-shared-deps-npm/version_dependencies.txt +++ b/src/platform/packages/private/kbn-ui-shared-deps-npm/version_dependencies.txt @@ -130,7 +130,7 @@ buffer@5.7.1 builtin-status-codes@3.0.0 call-bind-apply-helpers@1.0.2 call-bind@1.0.8 -call-bound@1.0.3 +call-bound@1.0.4 callsites@3.1.0 camelize@1.0.0 caniuse-lite@1.0.30001754 diff --git a/src/platform/packages/shared/deeplinks/observability/deep_links.ts b/src/platform/packages/shared/deeplinks/observability/deep_links.ts index 109228821259a..0d1831996b900 100644 --- a/src/platform/packages/shared/deeplinks/observability/deep_links.ts +++ b/src/platform/packages/shared/deeplinks/observability/deep_links.ts @@ -64,6 +64,7 @@ export type InventoryLinkId = 'datastreams'; export type ObservabilityOverviewLinkId = | 'alerts' + | 'alerts_v2' | 'cases' | 'cases_configure' | 'cases_create' diff --git a/src/platform/packages/shared/kbn-discover-utils/index.ts b/src/platform/packages/shared/kbn-discover-utils/index.ts index b0f80e8306b7f..05e3b8f6a1a5a 100644 --- a/src/platform/packages/shared/kbn-discover-utils/index.ts +++ b/src/platform/packages/shared/kbn-discover-utils/index.ts @@ -66,6 +66,7 @@ export { dismissAllFlyoutsExceptFor, dismissFlyouts, prepareDataViewForEditing, + getEsqlDataView, LogLevelBadge, getDefaultSort, getSort, diff --git a/src/platform/packages/shared/kbn-discover-utils/moon.yml b/src/platform/packages/shared/kbn-discover-utils/moon.yml index a8438687f66cf..3e57187f91da9 100644 --- a/src/platform/packages/shared/kbn-discover-utils/moon.yml +++ b/src/platform/packages/shared/kbn-discover-utils/moon.yml @@ -33,6 +33,8 @@ dependsOn: - '@kbn/core-ui-settings-browser' - '@kbn/apm-types' - '@kbn/core-chrome-app-menu-components' + - '@kbn/core-http-browser' + - '@kbn/esql-utils' tags: - shared-common - package diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts index 2c89527fc6d1d..c28737fdba93b 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts @@ -8,7 +8,7 @@ */ import { AppMenuRegistry } from './app_menu_registry'; -import type { AppMenuItemType, AppMenuPopoverItem } from '@kbn/core-chrome-app-menu-components'; +import type { DiscoverAppMenuItemType, DiscoverAppMenuPopoverItem } from '../../types'; describe('AppMenuRegistry', () => { let registry: AppMenuRegistry; @@ -19,7 +19,7 @@ describe('AppMenuRegistry', () => { describe('registerItem', () => { it('should register a single menu item', () => { - const item: AppMenuItemType = { + const item: DiscoverAppMenuItemType = { id: 'test-item', order: 1, label: 'Test Item', @@ -36,7 +36,7 @@ describe('AppMenuRegistry', () => { }); it('should update an existing item with the same ID', () => { - const item1: AppMenuItemType = { + const item1: DiscoverAppMenuItemType = { id: 'test-item', order: 1, label: 'Test Item 1', @@ -44,7 +44,7 @@ describe('AppMenuRegistry', () => { run: jest.fn(), }; - const item2: AppMenuItemType = { + const item2: DiscoverAppMenuItemType = { id: 'test-item', order: 2, label: 'Test Item 2', @@ -63,7 +63,7 @@ describe('AppMenuRegistry', () => { describe('registerItems', () => { it('should register multiple items at once', () => { - const items: AppMenuItemType[] = [ + const items: DiscoverAppMenuItemType[] = [ { id: 'item-1', order: 1, @@ -152,7 +152,7 @@ describe('AppMenuRegistry', () => { describe('registerPopoverItem', () => { it('should register a popover item under a parent menu item', () => { - const parentItem: AppMenuItemType = { + const parentItem: DiscoverAppMenuItemType = { id: 'parent', order: 1, label: 'Parent', @@ -160,7 +160,7 @@ describe('AppMenuRegistry', () => { items: [], }; - const popoverItem: AppMenuPopoverItem = { + const popoverItem: DiscoverAppMenuPopoverItem = { id: 'child-1', order: 1, label: 'Child 1', @@ -182,7 +182,7 @@ describe('AppMenuRegistry', () => { describe('registerCustomItem', () => { it('should register a custom menu item', () => { - const customItem: AppMenuItemType = { + const customItem: DiscoverAppMenuItemType = { id: 'custom-item', order: 1, label: 'Custom Item', @@ -199,7 +199,7 @@ describe('AppMenuRegistry', () => { }); it('should update an existing custom item with the same ID', () => { - const customItem1: AppMenuItemType = { + const customItem1: DiscoverAppMenuItemType = { id: 'custom-item', order: 1, label: 'Custom Item 1', @@ -207,7 +207,7 @@ describe('AppMenuRegistry', () => { run: jest.fn(), }; - const customItem2: AppMenuItemType = { + const customItem2: DiscoverAppMenuItemType = { id: 'custom-item', order: 2, label: 'Custom Item 2', @@ -224,7 +224,7 @@ describe('AppMenuRegistry', () => { }); it('should limit custom items to CUSTOM_ITEMS_LIMIT', () => { - const customItem1: AppMenuItemType = { + const customItem1: DiscoverAppMenuItemType = { id: 'custom-1', order: 1, label: 'Custom 1', @@ -232,7 +232,7 @@ describe('AppMenuRegistry', () => { run: jest.fn(), }; - const customItem2: AppMenuItemType = { + const customItem2: DiscoverAppMenuItemType = { id: 'custom-2', order: 2, label: 'Custom 2', @@ -240,7 +240,7 @@ describe('AppMenuRegistry', () => { run: jest.fn(), }; - const customItem3: AppMenuItemType = { + const customItem3: DiscoverAppMenuItemType = { id: 'custom-3', order: 3, label: 'Custom 3', @@ -260,7 +260,7 @@ describe('AppMenuRegistry', () => { }); it('should merge custom items with regular items', () => { - const regularItem: AppMenuItemType = { + const regularItem: DiscoverAppMenuItemType = { id: 'regular-item', order: 2, label: 'Regular Item', @@ -268,7 +268,7 @@ describe('AppMenuRegistry', () => { run: jest.fn(), }; - const customItem: AppMenuItemType = { + const customItem: DiscoverAppMenuItemType = { id: 'custom-item', order: 1, label: 'Custom Item', @@ -286,7 +286,7 @@ describe('AppMenuRegistry', () => { describe('registerCustomPopoverItem', () => { it('should register a popover item under a custom parent menu item', () => { - const parentItem: AppMenuItemType = { + const parentItem: DiscoverAppMenuItemType = { id: 'custom-parent', order: 1, label: 'Custom Parent', @@ -294,7 +294,7 @@ describe('AppMenuRegistry', () => { items: [], }; - const popoverItem: AppMenuPopoverItem = { + const popoverItem: DiscoverAppMenuPopoverItem = { id: 'custom-child-1', order: 1, label: 'Custom Child 1', @@ -314,7 +314,7 @@ describe('AppMenuRegistry', () => { }); it('should handle registering custom popover items before parent exists', () => { - const popoverItem: AppMenuPopoverItem = { + const popoverItem: DiscoverAppMenuPopoverItem = { id: 'custom-child-1', order: 1, label: 'Custom Child 1', @@ -324,7 +324,7 @@ describe('AppMenuRegistry', () => { // Register popover item first registry.registerCustomPopoverItem('custom-parent', popoverItem); - const parentItem: AppMenuItemType = { + const parentItem: DiscoverAppMenuItemType = { id: 'custom-parent', order: 1, label: 'Custom Parent', @@ -343,7 +343,7 @@ describe('AppMenuRegistry', () => { }); it('should handle multiple custom popover items with same order', () => { - const parentItem: AppMenuItemType = { + const parentItem: DiscoverAppMenuItemType = { id: 'custom-parent', order: 1, label: 'Custom Parent', @@ -351,14 +351,14 @@ describe('AppMenuRegistry', () => { items: [], }; - const popoverItem1: AppMenuPopoverItem = { + const popoverItem1: DiscoverAppMenuPopoverItem = { id: 'custom-child-1', label: 'Custom Child 1', order: 1, run: jest.fn(), }; - const popoverItem2: AppMenuPopoverItem = { + const popoverItem2: DiscoverAppMenuPopoverItem = { id: 'custom-child-2', label: 'Custom Child 2', order: 1, @@ -381,7 +381,7 @@ describe('AppMenuRegistry', () => { describe('getAppMenuConfig', () => { it('should return complete AppMenuConfig with all components', () => { - const item1: AppMenuItemType = { + const item1: DiscoverAppMenuItemType = { id: 'item-1', order: 1, label: 'Item 1', @@ -389,7 +389,7 @@ describe('AppMenuRegistry', () => { run: jest.fn(), }; - const item2: AppMenuItemType = { + const item2: DiscoverAppMenuItemType = { id: 'item-2', order: 2, label: 'Item 2', @@ -397,7 +397,7 @@ describe('AppMenuRegistry', () => { items: [], }; - const popoverItem: AppMenuPopoverItem = { + const popoverItem: DiscoverAppMenuPopoverItem = { id: 'popover-1', order: 1, label: 'Popover 1', @@ -442,7 +442,7 @@ describe('AppMenuRegistry', () => { }); it('should include both regular and custom items', () => { - const regularItem: AppMenuItemType = { + const regularItem: DiscoverAppMenuItemType = { id: 'regular', order: 2, label: 'Regular', @@ -450,7 +450,7 @@ describe('AppMenuRegistry', () => { run: jest.fn(), }; - const customItem: AppMenuItemType = { + const customItem: DiscoverAppMenuItemType = { id: 'custom', order: 1, label: 'Custom', @@ -466,4 +466,262 @@ describe('AppMenuRegistry', () => { expect(config.items).toHaveLength(2); }); }); + + describe('getItem', () => { + it('should return a registered item by ID', () => { + const item: DiscoverAppMenuItemType = { + id: 'test-item', + order: 1, + label: 'Test Item', + iconType: 'search', + run: jest.fn(), + }; + + registry.registerItem(item); + + const result = registry.getItem('test-item'); + expect(result).toEqual(item); + }); + + it('should return undefined for non-existent item', () => { + const result = registry.getItem('non-existent'); + expect(result).toBeUndefined(); + }); + + it('should return custom item by ID', () => { + const customItem: DiscoverAppMenuItemType = { + id: 'custom-item', + order: 1, + label: 'Custom Item', + iconType: 'beaker', + run: jest.fn(), + }; + + registry.registerCustomItem(customItem); + + const result = registry.getItem('custom-item'); + expect(result).toEqual(customItem); + }); + + it('should not include isCustom flag in returned item', () => { + const customItem: DiscoverAppMenuItemType = { + id: 'custom-item', + order: 1, + label: 'Custom Item', + iconType: 'beaker', + run: jest.fn(), + }; + + registry.registerCustomItem(customItem); + + const result = registry.getItem('custom-item'); + expect(result).not.toHaveProperty('isCustom'); + }); + }); + + describe('deleteItem', () => { + it('should remove a registered item by ID', () => { + const item: DiscoverAppMenuItemType = { + id: 'to-delete', + order: 1, + label: 'Delete Me', + iconType: 'trash', + run: jest.fn(), + }; + + registry.registerItem(item); + expect(registry.getItem('to-delete')).toBeDefined(); + + registry.deleteItem('to-delete'); + expect(registry.getItem('to-delete')).toBeUndefined(); + + const config = registry.getAppMenuConfig(); + expect(config.items).toHaveLength(0); + }); + + it('should not throw when deleting a non-existent item', () => { + expect(() => registry.deleteItem('non-existent')).not.toThrow(); + }); + + it('should only remove the specified item, leaving others intact', () => { + registry.registerItem({ + id: 'keep', + order: 1, + label: 'Keep', + iconType: 'check', + run: jest.fn(), + }); + registry.registerItem({ + id: 'remove', + order: 2, + label: 'Remove', + iconType: 'trash', + run: jest.fn(), + }); + + registry.deleteItem('remove'); + + const config = registry.getAppMenuConfig(); + expect(config.items).toHaveLength(1); + expect(config.items?.[0].id).toBe('keep'); + }); + }); + + describe('mergePopoverItems', () => { + it('should merge popover items from source menu into target submenu', () => { + const targetMenu: DiscoverAppMenuItemType = { + id: 'target-menu', + order: 1, + label: 'Target Menu', + iconType: 'alert', + items: [ + { + id: 'target-submenu', + order: 1, + label: 'Target Submenu', + items: [{ id: 'existing-item', order: 1, label: 'Existing', run: jest.fn() }], + }, + ], + }; + + const sourceMenu: DiscoverAppMenuItemType = { + id: 'source-menu', + order: 2, + label: 'Source Menu', + iconType: 'bell', + items: [ + { id: 'source-item-1', order: 2, label: 'Source 1', run: jest.fn() }, + { id: 'source-item-2', order: 3, label: 'Source 2', run: jest.fn() }, + ], + }; + + registry.registerItem(targetMenu); + registry.registerItem(sourceMenu); + + registry.mergePopoverItems('target-menu', 'target-submenu', 'source-menu'); + + const result = registry.getItem('target-menu'); + const submenu = result?.items?.find((item) => item.id === 'target-submenu'); + + expect(submenu?.items).toHaveLength(3); + expect(submenu?.items?.[0].id).toBe('existing-item'); + expect(submenu?.items?.[1].id).toBe('source-item-1'); + expect(submenu?.items?.[2].id).toBe('source-item-2'); + }); + + it('should do nothing when target menu does not exist', () => { + const sourceMenu: DiscoverAppMenuItemType = { + id: 'source-menu', + order: 1, + label: 'Source Menu', + iconType: 'bell', + items: [{ id: 'source-item', order: 1, label: 'Source', run: jest.fn() }], + }; + + registry.registerItem(sourceMenu); + + // Should not throw + registry.mergePopoverItems('non-existent', 'submenu', 'source-menu'); + + const config = registry.getAppMenuConfig(); + expect(config.items).toHaveLength(1); + }); + + it('should do nothing when source menu does not exist', () => { + const targetMenu: DiscoverAppMenuItemType = { + id: 'target-menu', + order: 1, + label: 'Target Menu', + iconType: 'alert', + items: [ + { + id: 'target-submenu', + order: 1, + label: 'Target Submenu', + items: [], + }, + ], + }; + + registry.registerItem(targetMenu); + + // Should not throw + registry.mergePopoverItems('target-menu', 'target-submenu', 'non-existent'); + + const result = registry.getItem('target-menu'); + const submenu = result?.items?.find((item) => item.id === 'target-submenu'); + expect(submenu?.items).toHaveLength(0); + }); + + it('should do nothing when source menu has no items', () => { + const targetMenu: DiscoverAppMenuItemType = { + id: 'target-menu', + order: 1, + label: 'Target Menu', + iconType: 'alert', + items: [ + { + id: 'target-submenu', + order: 1, + label: 'Target Submenu', + items: [{ id: 'existing', order: 1, label: 'Existing', run: jest.fn() }], + }, + ], + }; + + const sourceMenu: DiscoverAppMenuItemType = { + id: 'source-menu', + order: 2, + label: 'Source Menu', + iconType: 'bell', + items: [], + }; + + registry.registerItem(targetMenu); + registry.registerItem(sourceMenu); + + registry.mergePopoverItems('target-menu', 'target-submenu', 'source-menu'); + + const result = registry.getItem('target-menu'); + const submenu = result?.items?.find((item) => item.id === 'target-submenu'); + expect(submenu?.items).toHaveLength(1); + }); + + it('should sort merged items by order', () => { + const targetMenu: DiscoverAppMenuItemType = { + id: 'target-menu', + order: 1, + label: 'Target Menu', + iconType: 'alert', + items: [ + { + id: 'target-submenu', + order: 1, + label: 'Target Submenu', + items: [{ id: 'existing-high-order', order: 100, label: 'High Order', run: jest.fn() }], + }, + ], + }; + + const sourceMenu: DiscoverAppMenuItemType = { + id: 'source-menu', + order: 2, + label: 'Source Menu', + iconType: 'bell', + items: [{ id: 'source-low-order', order: 1, label: 'Low Order', run: jest.fn() }], + }; + + registry.registerItem(targetMenu); + registry.registerItem(sourceMenu); + + registry.mergePopoverItems('target-menu', 'target-submenu', 'source-menu'); + + const result = registry.getItem('target-menu'); + const submenu = result?.items?.find((item) => item.id === 'target-submenu'); + + // Items should be sorted by order + expect(submenu?.items?.[0].id).toBe('source-low-order'); + expect(submenu?.items?.[1].id).toBe('existing-high-order'); + }); + }); }); diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts index ee5646074f937..7f0969ac39425 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts @@ -95,6 +95,63 @@ export class AppMenuRegistry { } } + /** + * Remove a menu item by ID. + * @param id The ID of the menu item to remove + */ + public deleteItem(id: string): void { + this.items.delete(id); + } + + /** + * Get a menu item by ID. + * @param id The ID of the menu item to retrieve + * @returns The menu item or undefined if not found + */ + public getItem(id: string): DiscoverAppMenuItemType | undefined { + const item = this.items.get(id); + if (item) { + const { isCustom, ...cleanItem } = item; + return cleanItem; + } + return undefined; + } + + /** + * Merge popover items from a source menu into a target submenu. + * @param targetMenuId The ID of the target menu item + * @param targetSubmenuId The ID of the submenu within the target menu to merge items into + * @param sourceMenuId The ID of the source menu item whose items should be merged + */ + public mergePopoverItems( + targetMenuId: string, + targetSubmenuId: string, + sourceMenuId: string + ): void { + const targetMenu = this.items.get(targetMenuId); + const sourceMenu = this.items.get(sourceMenuId); + + if (!targetMenu || !sourceMenu || !sourceMenu.items?.length) { + return; + } + + const updatedItems = targetMenu.items?.map((item) => { + if (item.id === targetSubmenuId && item.items) { + // Sort items by order, putting source items before "manage rules" (which has MAX_SAFE_INTEGER order) + const mergedItems = [...item.items, ...sourceMenu.items!].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0) + ); + return { ...item, items: mergedItems }; + } + return item; + }); + + this.items.set(targetMenuId, { + ...targetMenu, + items: updatedItems, + }); + } + /** * Get the complete AppMenuConfig. * Items with registered popover items will have their items property populated. diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts index f4349fa407999..e0c45c5655923 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts @@ -51,10 +51,14 @@ export type DiscoverAppMenuRunAction = ( ) => ReactElement | void | null | ReactNode | Promise; /** - * Discover-specific popover item with typed run action + * Discover-specific popover item with typed run action and nested items */ -export type DiscoverAppMenuPopoverItem = Omit & { +export type DiscoverAppMenuPopoverItem = Omit & { run?: DiscoverAppMenuRunAction; + /** + * Sub-items for nested submenus (e.g., "Create legacy rules" submenu) + */ + items?: DiscoverAppMenuPopoverItem[]; }; /** diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/get_esql_data_view.test.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/get_esql_data_view.test.ts similarity index 77% rename from src/platform/plugins/shared/discover/public/application/main/state_management/utils/get_esql_data_view.test.ts rename to src/platform/packages/shared/kbn-discover-utils/src/utils/get_esql_data_view.test.ts index 7e69ea438cf2e..2831f1ec7aebe 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/get_esql_data_view.test.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/utils/get_esql_data_view.test.ts @@ -8,28 +8,44 @@ */ import type { DataView } from '@kbn/data-views-plugin/public'; +import type { HttpStart } from '@kbn/core-http-browser'; import { TIMEFIELD_ROUTE } from '@kbn/esql-types'; import { getEsqlDataView } from './get_esql_data_view'; -import { dataViewAdHoc } from '../../../../__mocks__/data_view_complex'; -import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; -import { discoverServiceMock } from '../../../../__mocks__/services'; +import { dataViewMock } from '../__mocks__'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; -describe('getEsqlDataView', () => { - discoverServiceMock.dataViews.create = jest.fn().mockImplementation((spec) => { - return Promise.resolve({ - ...dataViewMock, - isPersisted: () => false, - id: 'ad-hoc-id', - title: 'test', - timeFieldName: spec.timeFieldName, - }); - }); +const dataViewAdHoc = { + id: 'ad-hoc-id', + title: 'data-view-ad-hoc-title', + name: 'ES|QL ad-hoc data view', + isPersisted: () => false, + getIndexPattern: () => 'data-view-ad-hoc-title', + timeFieldName: '@timestamp', +} as unknown as DataView; +const mockDataViewsService = dataViewPluginMocks.createStartContract(); +jest.mocked(mockDataViewsService.create).mockImplementation((spec) => { + return Promise.resolve({ + ...dataViewMock, + isPersisted: () => false, + id: 'ad-hoc-id', + title: 'test', + timeFieldName: spec.timeFieldName, + } as DataView); +}); + +describe('getEsqlDataView', () => { const dataViewAdHocNoAtTimestamp = { ...dataViewAdHoc, timeFieldName: undefined, } as DataView; - const services = discoverServiceMock; + + const services = { + dataViews: mockDataViewsService, + http: { + get: jest.fn(), + } as unknown as HttpStart, + }; const mockGetTimeFieldRoute = (query: string, timeFieldResponse: string) => { const originalHttpGet = services.http.get; @@ -68,7 +84,7 @@ describe('getEsqlDataView', () => { }); it('creates an adhoc dataview if the current dataview is ad hoc and query index pattern is different from the dataview index pattern', async () => { - discoverServiceMock.dataViews.create = jest.fn().mockReturnValue({ + services.dataViews.create = jest.fn().mockReturnValue({ ...dataViewAdHoc, isPersisted: () => false, id: 'ad-hoc-id-1', @@ -82,7 +98,7 @@ describe('getEsqlDataView', () => { }); it('creates an adhoc ES|QL dataview if the query doesnt have from command', async () => { - discoverServiceMock.dataViews.create = jest.fn().mockReturnValue({ + services.dataViews.create = jest.fn().mockReturnValue({ ...dataViewAdHoc, isPersisted: () => false, id: 'ad-hoc-id-1', diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/get_esql_data_view.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/get_esql_data_view.ts similarity index 90% rename from src/platform/plugins/shared/discover/public/application/main/state_management/utils/get_esql_data_view.ts rename to src/platform/packages/shared/kbn-discover-utils/src/utils/get_esql_data_view.ts index 134ed31864634..573de28b982cd 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/get_esql_data_view.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/utils/get_esql_data_view.ts @@ -14,12 +14,13 @@ import { getTimeFieldFromESQLQuery, } from '@kbn/esql-utils'; import type { DataView } from '@kbn/data-views-plugin/common'; -import type { DiscoverServices } from '../../../../build_services'; +import type { HttpStart } from '@kbn/core-http-browser'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; export async function getEsqlDataView( query: AggregateQuery, currentDataView: DataView | undefined, - services: DiscoverServices + services: { dataViews: DataViewsPublicPluginStart; http: HttpStart } ) { const indexPatternFromQuery = getIndexPatternFromESQLQuery(query.esql); // Convert undefined time fields to a string since '' and undefined are equivalent here diff --git a/src/platform/packages/shared/kbn-discover-utils/src/utils/index.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/index.ts index 70f074d60303a..dc41a53cb87cb 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/utils/index.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/utils/index.ts @@ -31,3 +31,4 @@ export * from './escape_preserve_highlight_tags'; export * from './sorting'; export { DiscoverFlyouts, dismissAllFlyoutsExceptFor, dismissFlyouts } from './dismiss_flyouts'; export { prepareDataViewForEditing } from './prepare_data_view_for_editing'; +export { getEsqlDataView } from './get_esql_data_view'; diff --git a/src/platform/packages/shared/kbn-discover-utils/tsconfig.json b/src/platform/packages/shared/kbn-discover-utils/tsconfig.json index c559416129e64..1c02f6c2888da 100644 --- a/src/platform/packages/shared/kbn-discover-utils/tsconfig.json +++ b/src/platform/packages/shared/kbn-discover-utils/tsconfig.json @@ -28,6 +28,8 @@ "@kbn/data-plugin", "@kbn/core-ui-settings-browser", "@kbn/apm-types", - "@kbn/core-chrome-app-menu-components" + "@kbn/core-chrome-app-menu-components", + "@kbn/core-http-browser", + "@kbn/esql-utils" ] } diff --git a/src/platform/packages/shared/kbn-es-mappings/src/types.ts b/src/platform/packages/shared/kbn-es-mappings/src/types.ts index 2594c4b8dfe72..376441deeaa9a 100644 --- a/src/platform/packages/shared/kbn-es-mappings/src/types.ts +++ b/src/platform/packages/shared/kbn-es-mappings/src/types.ts @@ -55,6 +55,7 @@ type SupportedMappingPropertyType = AllMappingPropertyType & | 'date_nanos' | 'double' | 'long' + | 'flattened' | 'object' | 'flattened' ); diff --git a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/alerting_v2/stateful/classic.stateful.config.ts b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/alerting_v2/stateful/classic.stateful.config.ts new file mode 100644 index 0000000000000..575d29b94f9ea --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/alerting_v2/stateful/classic.stateful.config.ts @@ -0,0 +1,19 @@ +/* + * 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 { ScoutServerConfig } from '../../../../../types'; +import { defaultConfig } from '../../default/stateful/base.config'; + +export const servers: ScoutServerConfig = { + ...defaultConfig, + kbnTestServer: { + ...defaultConfig.kbnTestServer, + serverArgs: [...defaultConfig.kbnTestServer.serverArgs, '--xpack.alerting_v2.enabled=true'], + }, +}; diff --git a/src/platform/plugins/shared/discover/kibana.jsonc b/src/platform/plugins/shared/discover/kibana.jsonc index 3ec6bcc794c90..705962ce811ee 100644 --- a/src/platform/plugins/shared/discover/kibana.jsonc +++ b/src/platform/plugins/shared/discover/kibana.jsonc @@ -1,7 +1,9 @@ { "type": "plugin", "id": "@kbn/discover-plugin", - "owner": ["@elastic/kibana-data-discovery"], + "owner": [ + "@elastic/kibana-data-discovery" + ], "group": "platform", "visibility": "shared", "description": "This plugin contains the Discover application and the saved search embeddable.", @@ -48,7 +50,8 @@ "logsDataAccess", "apmSourcesAccess", "fileUpload", - "cps" + "cps", + "alertingVTwo" ], "requiredBundles": [ "kibanaUtils", @@ -58,6 +61,8 @@ "presentationPanel", "esql" ], - "extraPublicDirs": ["common"] + "extraPublicDirs": [ + "common" + ] } -} +} \ No newline at end of file diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index 6a9d3b3d5286b..a555e6fe8e997 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -133,6 +133,7 @@ dependsOn: - '@kbn/scout' - '@kbn/scout-synthtrace' - '@kbn/synthtrace-client' + - '@kbn/alerting-v2-plugin' - '@kbn/cps-utils' - '@kbn/as-code-data-views-schema' tags: diff --git a/src/platform/plugins/shared/discover/public/__mocks__/services.ts b/src/platform/plugins/shared/discover/public/__mocks__/services.ts index cc67bd4b3d815..06fdca2f5f14b 100644 --- a/src/platform/plugins/shared/discover/public/__mocks__/services.ts +++ b/src/platform/plugins/shared/discover/public/__mocks__/services.ts @@ -338,6 +338,9 @@ export function createDiscoverServicesMock(): DiscoverServices { getByValueInput: jest.fn(), clearEditorState: jest.fn(), }, + alertingVTwo: { + DynamicRuleFormFlyout: jest.fn(() => null), + }, trackUiMetric: jest.fn(), } as unknown as DiscoverServices; } diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx index b09523768db7c..10cb6b87e0981 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx @@ -147,7 +147,7 @@ export const getAlertsAppMenuItem = ({ }), iconType: 'tableOfContents', testId: 'discoverManageAlertsButton', - href: services.application.getUrlForApp('rules'), + href: getManageRulesUrl(services), }); if (discoverParams.authorizedRuleTypeIds.includes(ES_QUERY_ID)) { @@ -197,3 +197,11 @@ function getTimeField(dataView: DataView | undefined) { const dateFields = dataView?.fields.getByType('date'); return dataView?.timeFieldName || dateFields?.[0]?.name; } + +function getManageRulesUrl(services: DiscoverServices) { + return services.application.getUrlForApp( + services.application.isAppRegistered('rules') + ? 'rules' + : 'management/insightsAndAlerting/triggersActions/rules' + ); +} diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_create_rule.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_create_rule.test.tsx new file mode 100644 index 0000000000000..daafada180109 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_create_rule.test.tsx @@ -0,0 +1,204 @@ +/* + * 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 from 'react'; +import { render, act } from '@testing-library/react'; +import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import { ES_QUERY_ID } from '@kbn/rule-data-utils'; +import { AppMenuActionId } from '@kbn/discover-utils'; +import { getCreateRuleMenuItem, CreateESQLRuleFlyout } from './get_create_rule'; +import { discoverServiceMock } from '../../../../../__mocks__/services'; +import { getDiscoverStateMock } from '../../../../../__mocks__/discover_state.mock'; +import type { AppMenuExtensionParams } from '../../../../../context_awareness'; +import type { DiscoverAppMenuItemType } from '@kbn/discover-utils'; +import { internalStateActions } from '../../../state_management/redux'; + +// Mock CreateAlertFlyout from get_alerts +jest.mock('./get_alerts', () => ({ + CreateAlertFlyout: () =>
, + getManageRulesUrl: () => '/app/management/insightsAndAlerting/triggersActions/rules', + getTimeField: (dataView: { + timeFieldName?: string; + fields?: { getByType?: (type: string) => { name: string }[] }; + }) => dataView?.timeFieldName || dataView?.fields?.getByType?.('date')?.[0]?.name, +})); + +const getCreateRuleMenu = ( + dataView = dataViewMock, + isEsqlMode = true, + authorizedRuleTypeIds = [ES_QUERY_ID] +): DiscoverAppMenuItemType => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.internalState.dispatch( + stateContainer.injectCurrentTab(internalStateActions.assignNextDataView)({ + dataView, + }) + ); + + const tabId = stateContainer.getCurrentTab().id; + const getState = () => stateContainer.internalState.getState(); + + const discoverParamsMock: AppMenuExtensionParams = { + dataView, + adHocDataViews: [], + isEsqlMode, + authorizedRuleTypeIds, + actions: { + updateAdHocDataViews: jest.fn(), + }, + }; + + return getCreateRuleMenuItem({ + discoverParams: discoverParamsMock, + services: discoverServiceMock, + tabId, + getState, + }); +}; + +describe('getCreateRuleMenuItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Menu Structure', () => { + it('should return a menu item with the correct id', () => { + const menuItem = getCreateRuleMenu(); + expect(menuItem.id).toBe(AppMenuActionId.createRule); + }); + + it('should have the correct label and testId for the main menu button', () => { + const menuItem = getCreateRuleMenu(); + expect(menuItem.label).toBe('Rules'); + expect(menuItem.testId).toBe('discoverRulesMenuButton'); + }); + + it('should have the bell icon', () => { + const menuItem = getCreateRuleMenu(); + expect(menuItem.iconType).toBe('bell'); + }); + + it('should have tooltip content', () => { + const menuItem = getCreateRuleMenu(); + expect(menuItem.tooltipContent).toBe('Create alerting rules from this query'); + }); + + it('should have nested items array', () => { + const menuItem = getCreateRuleMenu(); + expect(menuItem.items).toBeDefined(); + expect(Array.isArray(menuItem.items)).toBe(true); + }); + + describe('Nested Items', () => { + it('should have "Create v2 ES|QL rule" item for ES|QL rules', () => { + const menuItem = getCreateRuleMenu(); + const createRuleItem = menuItem.items?.find((item) => item.id === 'create-rule'); + + expect(createRuleItem).toBeDefined(); + expect(createRuleItem?.label).toBe('Create v2 ES|QL rule'); + expect(createRuleItem?.testId).toBe('discoverCreateRuleButton'); + expect(createRuleItem?.iconType).toBe('bell'); + expect(createRuleItem?.order).toBe(1); + expect(createRuleItem?.run).toBeDefined(); + expect(typeof createRuleItem?.run).toBe('function'); + }); + + it('should have "Create v1 rules" submenu that starts empty (populated at merge time)', () => { + const menuItem = getCreateRuleMenu(); + const legacyRulesItem = menuItem.items?.find((item) => item.id === 'legacy-rules'); + + expect(legacyRulesItem).toBeDefined(); + expect(legacyRulesItem?.label).toBe('Create v1 rules'); + expect(legacyRulesItem?.testId).toBe('discoverLegacyRulesButton'); + expect(legacyRulesItem?.order).toBe(2); + expect(legacyRulesItem?.items).toBeDefined(); + expect(legacyRulesItem?.items).toHaveLength(0); + }); + + it('should not include "Search threshold rule" when ES_QUERY_ID is not authorized', () => { + const menuItem = getCreateRuleMenu(dataViewMock, true, []); // No authorized rule types + const legacyRulesItem = menuItem.items?.find((item) => item.id === 'legacy-rules'); + const searchThresholdItem = legacyRulesItem?.items?.find( + (item) => item.id === 'legacy-search-threshold' + ); + + expect(searchThresholdItem).toBeUndefined(); + }); + }); + }); +}); + +describe('CreateESQLRuleFlyout', () => { + beforeEach(() => { + // Reset history to a known state before each test + discoverServiceMock.history.push('/app/discover'); + }); + + it('should NOT close flyout when query parameters change but pathname stays the same', () => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + const tabId = stateContainer.getCurrentTab().id; + const getState = () => stateContainer.internalState.getState(); + const onClose = jest.fn(); + + render( + + ); + + // Simulate query parameter change (same pathname, different search params) + act(() => { + discoverServiceMock.history.push('/app/discover?_a=(query:(esql:newQuery))'); + }); + + // Flyout should NOT close because pathname is still '/app/discover' + expect(onClose).not.toHaveBeenCalled(); + }); + + it('should close flyout when pathname changes (actual navigation)', () => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + const tabId = stateContainer.getCurrentTab().id; + const getState = () => stateContainer.internalState.getState(); + const onClose = jest.fn(); + + render( + + ); + + // Simulate actual navigation to a different route + act(() => { + discoverServiceMock.history.push('/app/dashboards'); + }); + + // Flyout SHOULD close because pathname changed + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_create_rule.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_create_rule.tsx new file mode 100644 index 0000000000000..07a9443341a8a --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_create_rule.tsx @@ -0,0 +1,123 @@ +/* + * 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, { useEffect, useRef } from 'react'; +import type { AggregateQuery } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import type { DiscoverAppMenuItemType, DiscoverAppMenuPopoverItem } from '@kbn/discover-utils'; +import { AppMenuActionId } from '@kbn/discover-utils'; +import type { DiscoverInternalState } from '../../../state_management/redux'; +import { selectTab } from '../../../state_management/redux/selectors'; +import type { AppMenuDiscoverParams } from './types'; +import type { DiscoverServices } from '../../../../../build_services'; + +export function CreateESQLRuleFlyout({ + services, + tabId, + getState, + onClose, +}: { + discoverParams: AppMenuDiscoverParams; + services: DiscoverServices; + tabId: string; + getState: () => DiscoverInternalState; + onClose: () => void; +}) { + const currentTab = selectTab(getState(), tabId); + const query = (currentTab.appState.query as AggregateQuery)?.esql || ''; + + const { history, core, alertingVTwo } = services; + const RuleFormFlyout = alertingVTwo!.DynamicRuleFormFlyout; + + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + const initialPathnameRef = useRef(history.location.pathname); + + useEffect(() => { + const unlisten = history.listen((location) => { + if (location.pathname !== initialPathnameRef.current) { + onCloseRef.current(); + } + }); + + const appChangeSubscription = core.application.currentAppId$.subscribe((appId) => { + if (appId && appId !== 'discover') { + onCloseRef.current(); + } + }); + + return () => { + unlisten(); + appChangeSubscription.unsubscribe(); + }; + }, [history, core.application.currentAppId$]); + + return ; +} + +export const getCreateRuleMenuItem = ({ + discoverParams, + services, + tabId, + getState, +}: { + discoverParams: AppMenuDiscoverParams; + services: DiscoverServices; + tabId: string; + getState: () => DiscoverInternalState; +}): DiscoverAppMenuItemType => { + const createRuleItem: DiscoverAppMenuPopoverItem = { + id: 'create-rule', + order: 1, + label: i18n.translate('discover.localMenu.createRuleTitle', { + defaultMessage: 'Create v2 ES|QL rule', + }), + iconType: 'bell', + testId: 'discoverCreateRuleButton', + run: ({ context: { onFinishAction } }) => { + return ( + + ); + }, + }; + + const legacyRulesItem: DiscoverAppMenuPopoverItem = { + id: 'legacy-rules', + order: 2, + label: i18n.translate('discover.localMenu.legacyRulesTitle', { + defaultMessage: 'Create v1 rules', + }), + testId: 'discoverLegacyRulesButton', + items: [], + }; + + const items: DiscoverAppMenuPopoverItem[] = [createRuleItem, legacyRulesItem]; + + return { + id: AppMenuActionId.createRule, + order: 3, + label: i18n.translate('discover.localMenu.ruleTitle', { + defaultMessage: 'Rules', + }), + iconType: 'bell', + testId: 'discoverRulesMenuButton', + tooltipContent: i18n.translate('discover.localMenu.ruleDescription', { + defaultMessage: 'Create alerting rules from this query', + }), + items, + popoverTestId: 'discoverRulesPopover', + }; +}; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index c4dc668a10b47..48a51974a3f8e 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -10,39 +10,66 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import { AppMenuActionId } from '@kbn/discover-utils'; import { BehaviorSubject } from 'rxjs'; +import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; import { useTopNavLinks } from './use_top_nav_links'; import { getDiscoverInternalStateMock } from '../../../../__mocks__/discover_state.mock'; import { createDiscoverServicesMock } from '../../../../__mocks__/services'; import { DiscoverToolkitTestProvider } from '../../../../__mocks__/test_provider'; +import { internalStateActions } from '../../state_management/redux'; import { ENABLE_ESQL } from '@kbn/esql-utils'; import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; +import type { DiscoverServices } from '../../../../build_services'; jest.mock('@kbn/alerts-ui-shared', () => ({ ...jest.requireActual('@kbn/alerts-ui-shared'), - useGetRuleTypesPermissions: jest.fn().mockReturnValue({ authorizedRuleTypes: [] }), + useGetRuleTypesPermissions: jest.fn(() => ({ + authorizedRuleTypes: [ + { + id: '.es-query', + authorizedConsumers: { + discover: { all: true, read: true }, + }, + }, + ], + })), })); -describe('useTopNavLinks', () => { - const getServices = () => { - const services = createDiscoverServicesMock(); - const uiSettingsGetMock = services.uiSettings.get; - - services.share = sharePluginMock.createStartContract(); - services.application.currentAppId$ = new BehaviorSubject('discover'); - services.capabilities.discover_v2 = { - save: true, - storeSearchSession: true, - }; - services.uiSettings.get = (key: string) => { - return key === ENABLE_ESQL ? (true as T) : uiSettingsGetMock(key); - }; +const createTestServices = (overrides: Partial = {}): DiscoverServices => { + const services = createDiscoverServicesMock(); + const uiSettingsGetMock = services.uiSettings.get; - return services; + services.share = sharePluginMock.createStartContract(); + services.application.currentAppId$ = new BehaviorSubject('discover'); + services.capabilities.discover_v2 = { + save: true, + storeSearchSession: true, + }; + services.capabilities.management = { + ...services.capabilities.management, + insightsAndAlerting: { + triggersActions: true, + }, }; + services.uiSettings.get = (key: string) => { + return key === ENABLE_ESQL ? (true as T) : uiSettingsGetMock(key); + }; + + // Apply overrides + return { + ...services, + ...overrides, + capabilities: { + ...services.capabilities, + ...overrides.capabilities, + }, + } as DiscoverServices; +}; +describe('useTopNavLinks', () => { const setup = async (hookAttrs: Partial[0]> = {}) => { - const services = hookAttrs.services ?? getServices(); + const services = hookAttrs.services ?? createTestServices(); const toolkit = getDiscoverInternalStateMock({ services }); await toolkit.initializeTabs(); @@ -114,7 +141,7 @@ describe('useTopNavLinks', () => { describe('when share service included', () => { it('should include the share menu item', async () => { - const services = getServices(); + const services = createTestServices(); jest.spyOn(services.share!, 'availableIntegrations').mockReturnValue([]); @@ -129,7 +156,7 @@ describe('useTopNavLinks', () => { }); it('should include the export menu item', async () => { - const services = getServices(); + const services = createTestServices(); jest .spyOn(services.share!, 'availableIntegrations') @@ -163,7 +190,7 @@ describe('useTopNavLinks', () => { describe('when background search is enabled', () => { it('should return the background search menu item', async () => { - const services = getServices(); + const services = createTestServices(); services.data.search.isBackgroundSearchEnabled = true; const appMenuConfig = await setup({ services }); @@ -185,6 +212,35 @@ describe('useTopNavLinks', () => { }); }); + describe('save as button', () => { + it('should disable save as button when session is not persisted', async () => { + const appMenuConfig = await setup({ persistedDiscoverSession: undefined }); + + const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; + const saveAsItem = items?.find((item) => item.id === 'saveAs'); + + expect(saveAsItem?.disableButton).toBe(true); + }); + + it('should enable save as button when session is persisted', async () => { + const persistedSession = { + id: 'test-session-id', + title: 'Test Session', + description: 'Test Description', + tags: [], + managed: false, + tabs: [], + timeRestore: false, + }; + const appMenuConfig = await setup({ persistedDiscoverSession: persistedSession }); + + const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; + const saveAsItem = items?.find((item) => item.id === 'saveAs'); + + expect(saveAsItem?.disableButton).toBe(false); + }); + }); + describe('save button with unsaved changes', () => { it('should show notification indicator when there are unsaved changes', async () => { const appMenuConfig = await setup({ hasUnsavedChanges: true }); @@ -264,32 +320,109 @@ describe('useTopNavLinks', () => { }); }); - describe('save as button', () => { - it('should disable save as button when session is not persisted', async () => { - const appMenuConfig = await setup({ persistedDiscoverSession: undefined }); + describe('alerting v2 rules menu', () => { + const setupWithAlertingV2 = async ( + hookAttrs: Partial[0]> = {}, + alertingV2Enabled = true + ) => { + const baseMock = createDiscoverServicesMock(); + const v2Services = createTestServices({ + capabilities: { + ...baseMock.capabilities, + discover_v2: { + save: true, + storeSearchSession: true, + }, + ...(alertingV2Enabled ? { alertingVTwo: {} } : {}), + management: { + ...baseMock.capabilities.management, + insightsAndAlerting: { + triggersActions: true, + }, + }, + }, + triggersActionsUi: triggersActionsUiMock.createStart(), + }); - const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; - const saveAsItem = items?.find((item) => item.id === 'saveAs'); + const toolkit = getDiscoverInternalStateMock({ services: v2Services }); + await toolkit.initializeTabs(); + await toolkit.initializeSingleTab({ + tabId: toolkit.getCurrentTab().id, + }); + toolkit.internalState.dispatch( + toolkit.injectCurrentTab(internalStateActions.assignNextDataView)({ + dataView: dataViewMock, + }) + ); - expect(saveAsItem?.disableButton).toBe(true); + return renderHook( + () => + useTopNavLinks({ + dataView: dataViewMock, + onOpenInspector: jest.fn(), + services: v2Services, + hasUnsavedChanges: false, + isEsqlMode: true, + adHocDataViews: [], + hasShareIntegration: false, + persistedDiscoverSession: undefined, + ...hookAttrs, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ).result.current; + }; + + it('should include the createRule menu item when in ES|QL mode and alerting v2 is enabled', async () => { + const appMenuConfig = await setupWithAlertingV2({ isEsqlMode: true }, true); + + const createRuleItem = appMenuConfig.items?.find( + (item) => item.id === AppMenuActionId.createRule + ); + expect(createRuleItem).toBeDefined(); + expect(createRuleItem?.label).toBe('Rules'); }); - it('should enable save as button when session is persisted', async () => { - const persistedSession = { - id: 'test-session-id', - title: 'Test Session', - description: 'Test Description', - tags: [], - managed: false, - tabs: [], - timeRestore: false, - }; - const appMenuConfig = await setup({ persistedDiscoverSession: persistedSession }); + it('should NOT include the createRule menu item when not in ES|QL mode', async () => { + const appMenuConfig = await setupWithAlertingV2({ isEsqlMode: false }, true); - const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; - const saveAsItem = items?.find((item) => item.id === 'saveAs'); + const createRuleItem = appMenuConfig.items?.find( + (item) => item.id === AppMenuActionId.createRule + ); + expect(createRuleItem).toBeUndefined(); + }); - expect(saveAsItem?.disableButton).toBe(false); + it('should NOT include the createRule menu item when alerting v2 is disabled', async () => { + const appMenuConfig = await setupWithAlertingV2({ isEsqlMode: true }, false); + + const createRuleItem = appMenuConfig.items?.find( + (item) => item.id === AppMenuActionId.createRule + ); + expect(createRuleItem).toBeUndefined(); + }); + + it('should include the legacy alerts menu when not in ES|QL mode', async () => { + const appMenuConfig = await setupWithAlertingV2({ isEsqlMode: false }, true); + + const alertsItem = appMenuConfig.items?.find((item) => item.id === AppMenuActionId.alerts); + expect(alertsItem).toBeDefined(); + }); + + it('should NOT include the legacy alerts menu when in ES|QL mode and v2 is enabled', async () => { + const appMenuConfig = await setupWithAlertingV2({ isEsqlMode: true }, true); + + const alertsItem = appMenuConfig.items?.find((item) => item.id === AppMenuActionId.alerts); + expect(alertsItem).toBeUndefined(); + }); + + it('should include legacy alerts menu when in ES|QL mode but v2 is disabled', async () => { + const appMenuConfig = await setupWithAlertingV2({ isEsqlMode: true }, false); + + const alertsItem = appMenuConfig.items?.find((item) => item.id === AppMenuActionId.alerts); + expect(alertsItem).toBeDefined(); }); }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index dfa6861832274..bcdb0068686ac 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -14,7 +14,12 @@ import { METRIC_TYPE } from '@kbn/analytics'; import { ENABLE_ESQL, getInitialESQLQuery } from '@kbn/esql-utils'; import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import type { DiscoverAppMenuItemType } from '@kbn/discover-utils'; -import { AppMenuRegistry, dismissFlyouts, DiscoverFlyouts } from '@kbn/discover-utils'; +import { + AppMenuActionId, + AppMenuRegistry, + dismissFlyouts, + DiscoverFlyouts, +} from '@kbn/discover-utils'; import { ESQL_TYPE } from '@kbn/data-view-utils'; import { DISCOVER_APP_ID } from '@kbn/deeplinks-analytics'; import type { RuleTypeWithDescription } from '@kbn/alerts-ui-shared'; @@ -50,6 +55,7 @@ import type { DiscoverAppState } from '../../state_management/redux'; import { onSaveDiscoverSession } from './save_discover_session'; import { useDataState } from '../../hooks/use_data_state'; import { TransferAction } from '../../../../plugin_imports/embeddable_editor_service'; +import { getCreateRuleMenuItem } from './app_menu_actions/get_create_rule'; /** * Helper function to build the top nav links @@ -112,12 +118,25 @@ export const useTopNavLinks = ({ [isEsqlMode, dataView, adHocDataViews, dispatch, authorizedRuleTypes] ); + const canCreateESQLRule = !!services.capabilities.alertingVTwo; + const showCreateRuleV2 = isEsqlMode && canCreateESQLRule; + const appMenuItems: DiscoverAppMenuItemType[] = useMemo(() => { const items: DiscoverAppMenuItemType[] = []; const inspectAppMenuItem = getInspectAppMenuItem({ onOpenInspector }); items.push(inspectAppMenuItem); + if (showCreateRuleV2) { + const createRuleV2 = getCreateRuleMenuItem({ + discoverParams, + services, + tabId: currentTab.id, + getState, + }); + items.push(createRuleV2); + } + if (services.triggersActionsUi && discoverParams.authorizedRuleTypeIds.length) { const alertsAppMenuItem = getAlertsAppMenuItem({ discoverParams, @@ -209,6 +228,7 @@ export const useTopNavLinks = ({ hasUnsavedChanges, totalHitsState, intl, + showCreateRuleV2, ]); const transitionFromDataViewToESQL = useCurrentTabAction( @@ -216,6 +236,7 @@ export const useTopNavLinks = ({ ); const getAppMenuAccessor = useProfileAccessor('getAppMenu'); + const appMenuRegistry = useMemo(() => { const newAppMenuRegistry = new AppMenuRegistry(); @@ -361,7 +382,21 @@ export const useTopNavLinks = ({ appMenuRegistry: () => newAppMenuRegistry, })); - return getAppMenu(discoverParams).appMenuRegistry(newAppMenuRegistry); + const registry = getAppMenu(discoverParams).appMenuRegistry(newAppMenuRegistry); + + // When v2 rules are enabled, profile extensions have registered their rule types + // into the alerts menu as usual. Move those items into the v2 createRule menu's + // legacy-rules submenu, then remove the alerts menu since v2 replaces it. + if (showCreateRuleV2) { + registry.mergePopoverItems( + AppMenuActionId.createRule, + 'legacy-rules', + AppMenuActionId.alerts + ); + registry.deleteItem(AppMenuActionId.alerts); + } + + return registry; }, [ getAppMenuAccessor, discoverParams, @@ -374,6 +409,7 @@ export const useTopNavLinks = ({ runtimeStateManager, hasUnsavedChanges, transitionFromDataViewToESQL, + showCreateRuleV2, persistedDiscoverSession, ]); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_data_state_container.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_data_state_container.ts index 606ee6ad319a9..591656e094452 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_data_state_container.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_data_state_container.ts @@ -26,12 +26,15 @@ import type { AggregateQuery, Query } from '@kbn/es-query'; import { isOfAggregateQueryType } from '@kbn/es-query'; import type { SearchResponseWarning } from '@kbn/search-response-warnings'; import type { DataTableRecord } from '@kbn/discover-utils/types'; -import { DEFAULT_COLUMNS_SETTING, SEARCH_ON_PAGE_LOAD_SETTING } from '@kbn/discover-utils'; +import { + DEFAULT_COLUMNS_SETTING, + SEARCH_ON_PAGE_LOAD_SETTING, + getEsqlDataView, +} from '@kbn/discover-utils'; import { getTimeDifferenceInSeconds } from '@kbn/timerange'; import { AbortReason } from '@kbn/kibana-utils-plugin/common'; import { getESQLStatsQueryMeta } from '@kbn/esql-utils'; import { isEqual, sortBy } from 'lodash'; -import { getEsqlDataView } from './utils/get_esql_data_view'; import type { DiscoverServices } from '../../../build_services'; import type { DiscoverSearchSessionManager } from './discover_search_session'; import { FetchStatus } from '../../types'; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/initialize_single_tab.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/initialize_single_tab.ts index 875b65efa5e4b..fd616ad31429a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/initialize_single_tab.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/initialize_single_tab.ts @@ -13,6 +13,7 @@ import { cloneDeep, isEqual, isObject, pick } from 'lodash'; import type { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import type { ControlPanelsState } from '@kbn/control-group-renderer'; import type { OptionsListESQLControlState } from '@kbn/controls-schemas'; +import { getEsqlDataView } from '@kbn/discover-utils'; import { internalStateSlice, type TabActionPayload } from '../internal_state'; import { getInitialAppState } from '../../utils/get_initial_app_state'; import { type DiscoverAppState } from '..'; @@ -20,7 +21,6 @@ import type { DiscoverDataStateContainer } from '../../discover_data_state_conta import { appendAdHocDataViews } from './data_views'; import { setDataView } from './tab_state_data_view'; import { type AppStateUrl, cleanupUrlState } from '../../utils/cleanup_url_state'; -import { getEsqlDataView } from '../../utils/get_esql_data_view'; import { loadAndResolveDataView } from '../../utils/resolve_data_view'; import { isDataViewSource } from '../../../../../../common/data_sources'; import { isRefreshIntervalValid, isTimeRangeValid } from '../../../../../utils/validate_time'; diff --git a/src/platform/plugins/shared/discover/public/build_services.ts b/src/platform/plugins/shared/discover/public/build_services.ts index c8128312a2222..fbdc0a2ca3cfa 100644 --- a/src/platform/plugins/shared/discover/public/build_services.ts +++ b/src/platform/plugins/shared/discover/public/build_services.ts @@ -64,6 +64,7 @@ import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/publ import type { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public'; import type { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public'; import type { CPSPluginStart } from '@kbn/cps/public'; +import type { AlertingV2PublicStart } from '@kbn/alerting-v2-plugin/public'; import type { DiscoverStartPlugins } from './types'; import type { DiscoverContextAppLocator } from './application/context/services/locator'; import type { DiscoverSingleDocLocator } from './application/doc/locator'; @@ -96,6 +97,7 @@ export interface DiscoverFeatureFlags { export interface DiscoverServices { aiops?: AiopsPluginStart; + alertingVTwo?: AlertingV2PublicStart; application: ApplicationStart; addBasePath: (path: string) => string; analytics: AnalyticsServiceStart; @@ -190,6 +192,7 @@ export const buildServices = ({ return { aiops: plugins.aiops, + alertingVTwo: plugins.alertingVTwo, application: core.application, addBasePath: core.http.basePath.prepend, analytics: core.analytics, diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx index a79b6ed314eb8..eb20457cf075b 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx @@ -87,7 +87,7 @@ const registerCustomThresholdRuleAction = ( ) => { if (!authorizedRuleTypeIds.includes(OBSERVABILITY_THRESHOLD_RULE_TYPE_ID)) return; - registry.registerPopoverItem(AppMenuActionId.alerts, { + const popoverItem = { id: 'custom-threshold-rule', order: 2, iconType: 'bell', @@ -96,7 +96,7 @@ const registerCustomThresholdRuleAction = ( defaultMessage: 'Create custom threshold rule', }), - run: ({ context: { onFinishAction } }) => { + run: ({ context: { onFinishAction } }: { context: { onFinishAction: () => void } }) => { const index = dataView?.toMinimalSpec(); const { filters, query } = data.query.getState(); @@ -137,7 +137,9 @@ const registerCustomThresholdRuleAction = ( /> ); }, - }); + }; + + registry.registerPopoverItem(AppMenuActionId.alerts, popoverItem); }; const registerCreateSLOAction = ( @@ -150,7 +152,7 @@ const registerCreateSLOAction = ( const hasSloPermission = application.capabilities.slo?.write; if (sloFeature && hasSloPermission) { - registry.registerPopoverItem(AppMenuActionId.alerts, { + const popoverItem = { id: 'create-slo', order: 3, label: i18n.translate('discover.observabilitySolution.appMenu.slo', { @@ -158,7 +160,7 @@ const registerCreateSLOAction = ( }), iconType: 'visGauge', testId: 'discoverAppMenuCreateSlo', - run: ({ context: { onFinishAction } }) => { + run: ({ context: { onFinishAction } }: { context: { onFinishAction: () => void } }) => { const index = dataView?.getIndexPattern(); const timestampField = dataView?.timeFieldName; const { filters, query: kqlQuery } = data.query.getState(); @@ -184,6 +186,8 @@ const registerCreateSLOAction = ( onClose: onFinishAction, }); }, - }); + }; + + registry.registerPopoverItem(AppMenuActionId.alerts, popoverItem); } }; diff --git a/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx b/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx index e21c716063185..aed2bd8584c8a 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx +++ b/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx @@ -35,6 +35,7 @@ import { import { getProjectRoutingFromEsqlQuery } from '@kbn/esql-utils'; import type { PublishesWritableTimeRange } from '@kbn/presentation-publishing/interfaces/fetch/publishes_unified_search'; import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common'; +import { getEsqlDataView } from '@kbn/discover-utils'; import type { DiscoverServices } from '../build_services'; import { EDITABLE_SAVED_SEARCH_KEYS } from '../../common/embeddable/constants'; import { getSearchEmbeddableDefaults } from './get_search_embeddable_defaults'; @@ -43,7 +44,6 @@ import type { SearchEmbeddableSerializedAttributes, SearchEmbeddableStateManager, } from './types'; -import { getEsqlDataView } from '../application/main/state_management/utils/get_esql_data_view'; const initializeSearchSource = async ( discoverServices: DiscoverServices, diff --git a/src/platform/plugins/shared/discover/public/types.ts b/src/platform/plugins/shared/discover/public/types.ts index 70e2df85798f4..e70a66a14b9ea 100644 --- a/src/platform/plugins/shared/discover/public/types.ts +++ b/src/platform/plugins/shared/discover/public/types.ts @@ -47,6 +47,7 @@ import type { ApmSourceAccessPluginStart } from '@kbn/apm-sources-access-plugin/ import type { Setup as InspectorPublicPluginSetup } from '@kbn/inspector-plugin/public/plugin'; import type { FileUploadPluginStart } from '@kbn/file-upload-plugin/public'; import type { CPSPluginStart } from '@kbn/cps/public'; +import type { AlertingV2PublicStart } from '@kbn/alerting-v2-plugin/public'; import type { DiscoverAppLocator } from '../common'; import type { DiscoverContainerProps } from './components/discover_container'; @@ -149,6 +150,7 @@ export interface DiscoverSetupPlugins { */ export interface DiscoverStartPlugins { aiops?: AiopsPluginStart; + alertingVTwo?: AlertingV2PublicStart; charts: ChartsPluginStart; contentManagement: ContentManagementPublicStart; data: DataPublicPluginStart; diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 0fc0d48b590f9..d1128855f2355 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -128,6 +128,7 @@ "@kbn/scout", "@kbn/scout-synthtrace", "@kbn/synthtrace-client", + "@kbn/alerting-v2-plugin", "@kbn/cps-utils", "@kbn/as-code-data-views-schema", ], diff --git a/tsconfig.base.json b/tsconfig.base.json index 916e63e515abd..612c02a69ff11 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -84,6 +84,14 @@ "@kbn/alerting-state-types/*": ["x-pack/platform/packages/private/kbn-alerting-state-types/*"], "@kbn/alerting-types": ["src/platform/packages/shared/kbn-alerting-types"], "@kbn/alerting-types/*": ["src/platform/packages/shared/kbn-alerting-types/*"], + "@kbn/alerting-v2-episodes-ui": ["x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui"], + "@kbn/alerting-v2-episodes-ui/*": ["x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/*"], + "@kbn/alerting-v2-plugin": ["x-pack/platform/plugins/shared/alerting_v2"], + "@kbn/alerting-v2-plugin/*": ["x-pack/platform/plugins/shared/alerting_v2/*"], + "@kbn/alerting-v2-rule-form": ["x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form"], + "@kbn/alerting-v2-rule-form/*": ["x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/*"], + "@kbn/alerting-v2-schemas": ["x-pack/platform/packages/shared/response-ops/alerting-v2-schemas"], + "@kbn/alerting-v2-schemas/*": ["x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/*"], "@kbn/alerts-as-data-utils": ["src/platform/packages/shared/kbn-alerts-as-data-utils"], "@kbn/alerts-as-data-utils/*": ["src/platform/packages/shared/kbn-alerts-as-data-utils/*"], "@kbn/alerts-grouping": ["x-pack/solutions/observability/packages/kbn-alerts-grouping"], @@ -2618,6 +2626,8 @@ "@kbn/workspaces/*": ["src/platform/packages/shared/kbn-workspaces/*"], "@kbn/xstate-utils": ["src/platform/packages/shared/kbn-xstate-utils"], "@kbn/xstate-utils/*": ["src/platform/packages/shared/kbn-xstate-utils/*"], + "@kbn/yaml-rule-editor": ["x-pack/platform/packages/shared/response-ops/yaml-rule-editor"], + "@kbn/yaml-rule-editor/*": ["x-pack/platform/packages/shared/response-ops/yaml-rule-editor/*"], "@kbn/yarn-install-scripts": ["packages/kbn-yarn-install-scripts"], "@kbn/yarn-install-scripts/*": ["packages/kbn-yarn-install-scripts/*"], "@kbn/yarn-lock-validator": ["packages/kbn-yarn-lock-validator"], diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/README.md b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/README.md new file mode 100644 index 0000000000000..27374301bf453 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/README.md @@ -0,0 +1,3 @@ +# @kbn/alerting-v2-episodes-ui + +React primitives and client-side logic for Alerting V2 Episodes UIs. diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/apis/fetch_alerting_episodes.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/apis/fetch_alerting_episodes.test.ts new file mode 100644 index 0000000000000..3ebca6c286112 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/apis/fetch_alerting_episodes.test.ts @@ -0,0 +1,158 @@ +/* + * 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 { ESQLVariableType } from '@kbn/esql-types'; +import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import { ALERTING_EPISODES_PAGINATED_QUERY } from '../constants'; +import { executeEsqlQuery } from '../utils/execute_esql_query'; +import { fetchAlertingEpisodes } from './fetch_alerting_episodes'; + +jest.mock('../utils/execute_esql_query'); + +const executeEsqlQueryMock = executeEsqlQuery as jest.MockedFunction; + +describe('fetchAlertingEpisodes', () => { + const mockExpressions = {} as ExpressionsStart; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call executeEsqlQuery with correct parameters for first page', () => { + const pageSize = 10; + + fetchAlertingEpisodes({ + pageSize, + services: { expressions: mockExpressions }, + }); + + expect(executeEsqlQueryMock).toHaveBeenCalledTimes(1); + expect(executeEsqlQueryMock).toHaveBeenCalledWith({ + expressions: mockExpressions, + query: ALERTING_EPISODES_PAGINATED_QUERY, + input: { + type: 'kibana_context', + esqlVariables: [ + { + key: 'lastEpisodeTimestamp', + value: null, + type: ESQLVariableType.VALUES, + }, + { + key: 'pageSize', + value: pageSize, + type: ESQLVariableType.VALUES, + }, + ], + }, + abortSignal: undefined, + }); + }); + + it('should call executeEsqlQuery with correct parameters when beforeTimestamp is provided', () => { + const pageSize = 20; + const beforeTimestamp = '2024-01-15T10:30:00.000Z'; + + fetchAlertingEpisodes({ + pageSize, + beforeTimestamp, + services: { expressions: mockExpressions }, + }); + + expect(executeEsqlQueryMock).toHaveBeenCalledTimes(1); + expect(executeEsqlQueryMock).toHaveBeenCalledWith({ + expressions: mockExpressions, + query: ALERTING_EPISODES_PAGINATED_QUERY, + input: { + type: 'kibana_context', + esqlVariables: [ + { + key: 'lastEpisodeTimestamp', + value: beforeTimestamp, + type: ESQLVariableType.VALUES, + }, + { + key: 'pageSize', + value: pageSize, + type: ESQLVariableType.VALUES, + }, + ], + }, + abortSignal: undefined, + }); + }); + + it('should call executeEsqlQuery with abort signal when provided', () => { + const pageSize = 15; + const abortController = new AbortController(); + const abortSignal = abortController.signal; + + fetchAlertingEpisodes({ + pageSize, + abortSignal, + services: { expressions: mockExpressions }, + }); + + expect(executeEsqlQueryMock).toHaveBeenCalledTimes(1); + expect(executeEsqlQueryMock).toHaveBeenCalledWith({ + expressions: mockExpressions, + query: ALERTING_EPISODES_PAGINATED_QUERY, + input: { + type: 'kibana_context', + esqlVariables: [ + { + key: 'lastEpisodeTimestamp', + value: null, + type: ESQLVariableType.VALUES, + }, + { + key: 'pageSize', + value: pageSize, + type: ESQLVariableType.VALUES, + }, + ], + }, + abortSignal, + }); + }); + + it('should call executeEsqlQuery with all parameters provided', () => { + const pageSize = 25; + const beforeTimestamp = '2024-02-20T15:45:30.000Z'; + const abortController = new AbortController(); + const abortSignal = abortController.signal; + + fetchAlertingEpisodes({ + pageSize, + beforeTimestamp, + abortSignal, + services: { expressions: mockExpressions }, + }); + + expect(executeEsqlQueryMock).toHaveBeenCalledTimes(1); + expect(executeEsqlQueryMock).toHaveBeenCalledWith({ + expressions: mockExpressions, + query: ALERTING_EPISODES_PAGINATED_QUERY, + input: { + type: 'kibana_context', + esqlVariables: [ + { + key: 'lastEpisodeTimestamp', + value: beforeTimestamp, + type: ESQLVariableType.VALUES, + }, + { + key: 'pageSize', + value: pageSize, + type: ESQLVariableType.VALUES, + }, + ], + }, + abortSignal, + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/apis/fetch_alerting_episodes.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/apis/fetch_alerting_episodes.ts new file mode 100644 index 0000000000000..a9196787f6266 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/apis/fetch_alerting_episodes.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ESQLControlVariable } from '@kbn/esql-types'; +import { ESQLVariableType } from '@kbn/esql-types'; +import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import { + ALERTING_EPISODES_PAGINATED_QUERY, + LAST_EPISODE_TIMESTAMP_VARIABLE, + PAGE_SIZE_VARIABLE, +} from '../constants'; +import { executeEsqlQuery } from '../utils/execute_esql_query'; + +export interface FetchAlertingEpisodesOptions { + abortSignal?: AbortSignal; + beforeTimestamp?: string | null; + pageSize: number; + services: { expressions: ExpressionsStart }; +} + +/** + * Executes an ES|QL query to fetch alerting episodes. + * Uses the timestamp of the last episode from the previous page as a cursor for pagination. + */ +export const fetchAlertingEpisodes = ({ + abortSignal, + pageSize, + beforeTimestamp = null, + services: { expressions }, +}: FetchAlertingEpisodesOptions) => { + // With ES|QL, we can only paginate using a @timestamp cursor, so we use the timestamp + // of the last episode from the previous page as the cursor for the next page. + // For the first page, we use null to disable the condition. + return executeEsqlQuery({ + expressions, + query: ALERTING_EPISODES_PAGINATED_QUERY, + input: { + type: 'kibana_context', + esqlVariables: [ + { + key: LAST_EPISODE_TIMESTAMP_VARIABLE, + // null is not a valid type but works in practice + value: beforeTimestamp as ESQLControlVariable['value'], + type: ESQLVariableType.VALUES, + }, + { key: PAGE_SIZE_VARIABLE, value: pageSize, type: ESQLVariableType.VALUES }, + ] satisfies ESQLControlVariable[], + }, + abortSignal, + }); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.test.tsx new file mode 100644 index 0000000000000..00b85ea742ca7 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { AlertingEpisodeStatusBadge } from './alerting_episode_status_badge'; + +describe('AlertingEpisodeStatusBadge', () => { + it('renders an inactive badge', () => { + const { getByText } = render(); + const badge = getByText('Inactive'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); + }); + + it('renders a pending badge', () => { + const { getByText } = render(); + const badge = getByText('Pending'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); + }); + + it('renders an active badge', () => { + const { getByText } = render(); + const badge = getByText('Active'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); + }); + + it('renders a recovering badge', () => { + const { getByText } = render(); + const badge = getByText('Recovering'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); + }); + + it('renders an unknown badge for unrecognized status', () => { + // @ts-expect-error unknown status string + const { getByText } = render(); + const badge = getByText('Unknown'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.tsx new file mode 100644 index 0000000000000..905e583e62a8f --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.tsx @@ -0,0 +1,64 @@ +/* + * 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 { EuiBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import type { AlertEpisodeStatus } from '@kbn/alerting-v2-plugin/server/resources/alert_events'; + +export interface AlertingEpisodeStatusBadgeProps { + status: AlertEpisodeStatus; +} + +/** + * Renders a badge indicating the status of an alerting episode. + */ +export const AlertingEpisodeStatusBadge = ({ status }: AlertingEpisodeStatusBadgeProps) => { + if (status === 'inactive') { + return ( + + {i18n.translate('xpack.alertingV2EpisodesUi.inactiveStatusBadgeLabel', { + defaultMessage: 'Inactive', + })} + + ); + } + if (status === 'pending') { + return ( + + {i18n.translate('xpack.alertingV2EpisodesUi.pendingStatusBadgeLabel', { + defaultMessage: 'Pending', + })} + + ); + } + if (status === 'active') { + return ( + + {i18n.translate('xpack.alertingV2EpisodesUi.activeStatusBadgeLabel', { + defaultMessage: 'Active', + })} + + ); + } + if (status === 'recovering') { + return ( + + {i18n.translate('xpack.alertingV2EpisodesUi.recoveringStatusBadgeLabel', { + defaultMessage: 'Recovering', + })} + + ); + } + return ( + + {i18n.translate('xpack.alertingV2EpisodesUi.unknownStatusBadgeLabel', { + defaultMessage: 'Unknown', + })} + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/constants.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/constants.ts new file mode 100644 index 0000000000000..ed3ae9be4add3 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/constants.ts @@ -0,0 +1,32 @@ +/* + * 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 const LAST_EPISODE_TIMESTAMP_VARIABLE = 'lastEpisodeTimestamp'; +export const PAGE_SIZE_VARIABLE = 'pageSize'; + +/** + * A query to get aggregated alerting v2 episodes data with @timestamp/LIMIT-based pagination. + * + * This will be simplified when the `$.alert-episodes` ES|QL view is available. + */ +export const ALERTING_EPISODES_PAGINATED_QUERY = ` +FROM .rule-events +| WHERE ?${LAST_EPISODE_TIMESTAMP_VARIABLE} IS NULL OR @timestamp < ?${LAST_EPISODE_TIMESTAMP_VARIABLE} +| INLINE STATS first_timestamp = MIN(@timestamp), last_timestamp = MAX(@timestamp) BY episode.id +| EVAL duration = DATE_DIFF("ms", first_timestamp, last_timestamp) +| WHERE @timestamp == last_timestamp AND type == "alert" +| SORT @timestamp DESC +| LIMIT ?${PAGE_SIZE_VARIABLE} +`; + +/** + * A query to get the total count of alerting episodes (used for pagination). + */ +export const ALERTING_EPISODES_COUNT_QUERY = ` +FROM .rule-events +| STATS total = COUNT_DISTINCT(episode.id) +`; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_episodes_data_view.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_episodes_data_view.test.ts new file mode 100644 index 0000000000000..7a22350bf851a --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_episodes_data_view.test.ts @@ -0,0 +1,129 @@ +/* + * 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 { renderHook, waitFor } from '@testing-library/react'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { getEsqlDataView } from '@kbn/discover-utils'; +import { ALERTING_EPISODES_PAGINATED_QUERY } from '../constants'; +import { useAlertingEpisodesDataView } from './use_alerting_episodes_data_view'; + +jest.mock('@kbn/discover-utils'); + +const getEsqlDataViewMock = getEsqlDataView as jest.MockedFunction; + +describe('useAlertingEpisodesDataView', () => { + const http = httpServiceMock.createSetupContract(); + const { dataViews } = dataPluginMock.createStartContract(); + + const mockDataView = { + fields: [ + { name: 'rule.id' }, + { name: 'episode.status' }, + { name: '@timestamp' }, + { name: 'other.field' }, + ], + setFieldCustomLabel: jest.fn(), + setFieldFormat: jest.fn(), + addRuntimeField: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + getEsqlDataViewMock.mockResolvedValue(mockDataView as any); + }); + + it('should call getEsqlDataView with correct parameters using default query', async () => { + const services = { dataViews, http }; + + renderHook(() => useAlertingEpisodesDataView({ services })); + + await waitFor(() => { + expect(getEsqlDataViewMock).toHaveBeenCalledTimes(1); + }); + + expect(getEsqlDataViewMock).toHaveBeenCalledWith( + { esql: ALERTING_EPISODES_PAGINATED_QUERY }, + undefined, + services + ); + }); + + it('should call getEsqlDataView with custom query when provided', async () => { + const customQuery = 'FROM .custom-index | LIMIT 100'; + const services = { dataViews, http }; + + renderHook(() => useAlertingEpisodesDataView({ query: customQuery, services })); + + await waitFor(() => { + expect(getEsqlDataViewMock).toHaveBeenCalledTimes(1); + }); + + expect(getEsqlDataViewMock).toHaveBeenCalledWith({ esql: customQuery }, undefined, services); + }); + + it('should set custom labels for known fields', async () => { + const services = { dataViews, http }; + + renderHook(() => useAlertingEpisodesDataView({ services })); + + await waitFor(() => { + expect(mockDataView.setFieldCustomLabel).toHaveBeenCalled(); + }); + + expect(mockDataView.setFieldCustomLabel).toHaveBeenCalledWith('rule.id', 'Rule'); + expect(mockDataView.setFieldCustomLabel).toHaveBeenCalledWith('episode.status', 'Status'); + expect(mockDataView.setFieldCustomLabel).toHaveBeenCalledTimes(2); + }); + + it('should add runtime field for duration', async () => { + const services = { dataViews, http }; + + renderHook(() => useAlertingEpisodesDataView({ services })); + + await waitFor(() => { + expect(mockDataView.addRuntimeField).toHaveBeenCalled(); + }); + + expect(mockDataView.addRuntimeField).toHaveBeenCalledWith('duration', { + type: 'long', + customLabel: 'Duration', + format: { + id: 'duration', + params: { + includeSpaceWithSuffix: true, + inputFormat: 'seconds', + outputFormat: 'humanizePrecise', + outputPrecision: 2, + useShortSuffix: true, + }, + }, + }); + expect(mockDataView.addRuntimeField).toHaveBeenCalledTimes(1); + }); + + it('should return undefined when data view is not loaded yet', () => { + getEsqlDataViewMock.mockReturnValue(new Promise(() => {})); + const services = { dataViews, http }; + + const { result } = renderHook(() => useAlertingEpisodesDataView({ services })); + + expect(result.current).toBeUndefined(); + }); + + it('should return data view once loaded', async () => { + const services = { dataViews, http }; + + const { result } = renderHook(() => useAlertingEpisodesDataView({ services })); + + await waitFor(() => { + expect(result.current).toBeDefined(); + }); + + expect(result.current).toBe(mockDataView); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_episodes_data_view.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_episodes_data_view.ts new file mode 100644 index 0000000000000..3daa32c8a44ef --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_episodes_data_view.ts @@ -0,0 +1,96 @@ +/* + * 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 useAsync from 'react-use/lib/useAsync'; +import { getEsqlDataView } from '@kbn/discover-utils'; +import type { HttpStart } from '@kbn/core-http-browser'; +import type { DataViewsContract, RuntimeField } from '@kbn/data-views-plugin/public'; +import { useMemo } from 'react'; +import type { SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { ALERTING_EPISODES_PAGINATED_QUERY } from '../constants'; + +export interface UseAlertingEpisodesDataViewOptions { + query?: string; + services: { + dataViews: DataViewsContract; + http: HttpStart; + }; +} + +export interface KnownFieldOverrides { + customLabel?: string; + format?: Partial; +} + +const knownFieldsOverrides: Record = { + 'rule.id': { + customLabel: i18n.translate('xpack.alertingV2EpisodesUi.ruleFieldLabel', { + defaultMessage: 'Rule', + }), + }, + 'episode.status': { + customLabel: i18n.translate('xpack.alertingV2EpisodesUi.statusFieldLabel', { + defaultMessage: 'Status', + }), + }, +}; + +const computedFields: Record = { + duration: { + type: 'long', + customLabel: i18n.translate('xpack.alertingV2EpisodesUi.durationFieldLabel', { + defaultMessage: 'Duration', + }), + format: { + id: 'duration', + params: { + includeSpaceWithSuffix: true, + inputFormat: 'seconds', + outputFormat: 'humanizePrecise', + outputPrecision: 2, + useShortSuffix: true, + }, + }, + }, +}; + +/** + * Creates an ad-hoc data view for the alerting episodes query, enriching + * known fields with display names and value formats. + */ +export const useAlertingEpisodesDataView = ({ + query = ALERTING_EPISODES_PAGINATED_QUERY, + services, +}: UseAlertingEpisodesDataViewOptions) => { + const dataViewAsync = useAsync( + () => getEsqlDataView({ esql: query }, undefined, services), + [services] + ); + + return useMemo(() => { + const dataView = dataViewAsync.value; + if (dataView) { + dataView.fields.forEach((field) => { + const knownFieldOverrides = + knownFieldsOverrides[field.name as keyof typeof knownFieldsOverrides]; + if (knownFieldOverrides?.customLabel) { + dataView.setFieldCustomLabel(field.name, knownFieldOverrides.customLabel); + } + if (knownFieldOverrides?.format) { + dataView.setFieldFormat(field.name, knownFieldOverrides.format); + } + }); + Object.entries(computedFields).map(([name, overrides]) => { + dataView.addRuntimeField(name, { + ...overrides, + }); + }); + } + return dataView; + }, [dataViewAsync.value]); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_rules_index.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_rules_index.test.ts new file mode 100644 index 0000000000000..29a9c83215189 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_rules_index.test.ts @@ -0,0 +1,96 @@ +/* + * 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 { renderHook, waitFor } from '@testing-library/react'; +import { useAlertingRulesIndex } from './use_alerting_rules_index'; +import type { FindRulesResponse } from '@kbn/alerting-v2-plugin/public/services/rules_api'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; + +jest.mock('react-use/lib/useAsync', () => ({ + __esModule: true, + default: jest.fn((fn: () => Promise) => { + const result = fn(); + return { loading: false, error: undefined, value: result }; + }), +})); + +const mockHttp = httpServiceMock.createStartContract(); +const GET_RULES_BULK_ENDPOINT = '/internal/alerting/v2/rule/_bulk'; + +describe('useAlertingRulesIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return cached rules for already fetched rule IDs', async () => { + const ruleId = 'rule-1'; + const fetchedRule = { + id: ruleId, + name: 'Fetched Rule', + } as unknown as FindRulesResponse['items'][number]; + mockHttp.get.mockResolvedValue({ + items: [fetchedRule], + } as FindRulesResponse); + + const { result, rerender } = renderHook( + ({ ruleIds }: { ruleIds: string[] } = { ruleIds: [ruleId] }) => + useAlertingRulesIndex({ + ruleIds, + services: { http: mockHttp }, + }) + ); + + await waitFor(() => expect(result.current.rulesIndex).toEqual({ [ruleId]: fetchedRule })); + + rerender({ ruleIds: [ruleId] }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeUndefined(); + expect(mockHttp.get).toHaveBeenCalledTimes(1); + }); + + it('should fetch rules for uncached rule IDs', async () => { + const ruleId = 'rule-2'; + const fetchedRule = { + id: ruleId, + name: 'Fetched Rule', + } as unknown as FindRulesResponse['items'][number]; + mockHttp.get.mockResolvedValue({ + items: [fetchedRule], + } as FindRulesResponse); + + const { result } = renderHook(() => + useAlertingRulesIndex({ + ruleIds: [ruleId], + services: { http: mockHttp }, + }) + ); + + await waitFor(() => + expect(mockHttp.get).toHaveBeenCalledWith(GET_RULES_BULK_ENDPOINT, { + query: { ids: [ruleId] }, + }) + ); + expect(result.current.rulesIndex).toEqual({ [ruleId]: fetchedRule }); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeUndefined(); + }); + + it('should handle empty rule IDs array gracefully', async () => { + const { result } = renderHook(() => + useAlertingRulesIndex({ + ruleIds: [], + services: { http: mockHttp }, + }) + ); + + expect(mockHttp.get).not.toHaveBeenCalled(); + expect(result.current.rulesIndex).toEqual({}); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeUndefined(); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_rules_index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_rules_index.ts new file mode 100644 index 0000000000000..bc4f96bb7cf11 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_alerting_rules_index.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useRef } from 'react'; +import type { HttpStart } from '@kbn/core-http-browser'; +import type { FindRulesResponse } from '@kbn/alerting-v2-plugin/public/services/rules_api'; +import useAsync from 'react-use/lib/useAsync'; + +const GET_RULES_BULK_ENDPOINT = '/internal/alerting/v2/rule/_bulk'; + +export interface UseAlertingRulesIndexOptions { + ruleIds: string[]; + services: { + http: HttpStart; + }; +} + +type Rule = FindRulesResponse['items'][number]; + +/** + * Provides a rules index by id, fetching uncached rules + * with the minimum number of bulk requests possible + */ +export const useAlertingRulesIndex = ({ ruleIds, services }: UseAlertingRulesIndexOptions) => { + const cacheRef = useRef>({}); + + const { loading, error } = useAsync(async () => { + const uncachedIds = ruleIds.filter((id) => !cacheRef.current[id]); + + if (uncachedIds.length === 0) { + return; + } + + const rulesResponse = await services.http.get(GET_RULES_BULK_ENDPOINT, { + query: { ids: uncachedIds }, + }); + rulesResponse.items.forEach((rule) => { + cacheRef.current[rule.id] = rule; + }); + }, [ruleIds, services.http]); + + return { + rulesIndex: cacheRef.current, + loading, + error, + }; +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query.test.tsx new file mode 100644 index 0000000000000..cb5434e16de09 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query.test.tsx @@ -0,0 +1,330 @@ +/* + * 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 { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import type { Datatable, ExpressionsStart } from '@kbn/expressions-plugin/public'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { fetchAlertingEpisodes } from '../apis/fetch_alerting_episodes'; +import { executeEsqlQuery } from '../utils/execute_esql_query'; +import { ALERTING_EPISODES_COUNT_QUERY } from '../constants'; +import { useFetchAlertingEpisodesQuery } from './use_fetch_alerting_episodes_query'; +import type { PropsWithChildren } from 'react'; +import React from 'react'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { useAlertingEpisodesDataView } from './use_alerting_episodes_data_view'; +import type { DataView } from '@kbn/data-views-plugin/common'; + +jest.mock('../apis/fetch_alerting_episodes'); +jest.mock('../utils/execute_esql_query'); + +const fetchAlertingEpisodesMock = jest.mocked(fetchAlertingEpisodes); +const executeEsqlQueryMock = jest.mocked(executeEsqlQuery); + +jest.mock('./use_alerting_episodes_data_view'); +const mockDataView = { + fields: [{ name: '@timestamp' }, { name: 'episode.id' }], + setFieldCustomLabel: jest.fn(), + setFieldFormat: jest.fn(), + addRuntimeField: jest.fn(), +}; +const mockUseAlertingEpisodesDataView = jest + .mocked(useAlertingEpisodesDataView) + .mockReturnValue(mockDataView as unknown as DataView); + +const http = httpServiceMock.createStartContract(); +const { dataViews } = dataPluginMock.createStartContract(); +const mockExpressions = {} as ExpressionsStart; + +const mockEpisodesData = { + rows: [ + { '@timestamp': '2024-03-01T10:00:00Z', 'episode.id': 'episode-1' }, + { '@timestamp': '2024-03-01T09:00:00Z', 'episode.id': 'episode-2' }, + ], +} as unknown as Datatable; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + +describe('useFetchAlertingEpisodesQuery', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it('should fetch total count on first page', async () => { + const pageSize = 2; + const totalCount = 4; + + executeEsqlQueryMock.mockResolvedValue({ + rows: [{ total: totalCount }], + } as unknown as Datatable); + fetchAlertingEpisodesMock.mockResolvedValue(mockEpisodesData); + + renderHook( + () => + useFetchAlertingEpisodesQuery({ + pageSize, + services: { dataViews, http, expressions: mockExpressions }, + }), + { wrapper } + ); + + expect(executeEsqlQueryMock).toHaveBeenCalledWith({ + expressions: mockExpressions, + query: ALERTING_EPISODES_COUNT_QUERY, + input: null, + abortSignal: expect.any(AbortSignal), + }); + }); + + it('should fetch episodes data with correct page size', async () => { + const pageSize = 20; + const totalCount = 50; + + executeEsqlQueryMock.mockResolvedValue({ + rows: [{ total: totalCount }], + } as unknown as Datatable); + fetchAlertingEpisodesMock.mockResolvedValue(mockEpisodesData); + + const { result } = renderHook( + () => + useFetchAlertingEpisodesQuery({ + pageSize, + services: { dataViews, http, expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => result.current.isSuccess); + + expect(fetchAlertingEpisodesMock).toHaveBeenCalledWith({ + abortSignal: expect.any(AbortSignal), + pageSize, + beforeTimestamp: undefined, + services: { dataViews, http, expressions: mockExpressions }, + }); + }); + + it('should not fetch total count on subsequent pages', async () => { + const pageSize = 10; + const totalCount = 100; + + executeEsqlQueryMock.mockResolvedValue({ + rows: [{ total: totalCount }], + } as unknown as Datatable); + fetchAlertingEpisodesMock.mockResolvedValue(mockEpisodesData); + + const { result } = renderHook( + () => + useFetchAlertingEpisodesQuery({ + pageSize, + services: { dataViews, http, expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => result.current.isSuccess); + + executeEsqlQueryMock.mockClear(); + + await result.current.fetchNextPage(); + + await waitFor(() => { + expect(result.current.isFetchingNextPage).toBe(false); + }); + + expect(executeEsqlQueryMock).not.toHaveBeenCalled(); + }); + + it('should use timestamp from last row as cursor for next page', async () => { + const pageSize = 10; + const totalCount = 100; + const lastTimestamp = '2024-03-01T09:00:00Z'; + + executeEsqlQueryMock.mockResolvedValue({ + rows: [{ total: totalCount }], + } as unknown as Datatable); + fetchAlertingEpisodesMock.mockResolvedValue({ + rows: [ + { '@timestamp': '2024-03-01T10:00:00Z', 'episode.id': 'episode-1' }, + { '@timestamp': lastTimestamp, 'episode.id': 'episode-2' }, + ], + } as unknown as Datatable); + + const { result } = renderHook( + () => + useFetchAlertingEpisodesQuery({ + pageSize, + services: { dataViews, http, expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => result.current.isSuccess); + + fetchAlertingEpisodesMock.mockClear(); + + await result.current.fetchNextPage(); + + await waitFor(() => { + expect(result.current.isFetchingNextPage).toBe(false); + }); + + expect(fetchAlertingEpisodesMock).toHaveBeenCalledWith({ + abortSignal: expect.any(AbortSignal), + pageSize, + beforeTimestamp: lastTimestamp, + services: { dataViews, http, expressions: mockExpressions }, + }); + }); + + it('should return undefined for next page param when all data is fetched', async () => { + const pageSize = 10; + const totalCount = 2; + + executeEsqlQueryMock.mockResolvedValue({ + rows: [{ total: totalCount }], + } as unknown as Datatable); + fetchAlertingEpisodesMock.mockResolvedValue(mockEpisodesData); + + const { result } = renderHook( + () => + useFetchAlertingEpisodesQuery({ + pageSize, + services: { dataViews, http, expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.hasNextPage).toBe(false); + }); + }); + + it('should return next page param when more data is available', async () => { + const pageSize = 10; + const totalCount = 100; + + executeEsqlQueryMock.mockResolvedValue({ + rows: [{ total: totalCount }], + } as unknown as Datatable); + fetchAlertingEpisodesMock.mockResolvedValue(mockEpisodesData); + + const { result } = renderHook( + () => + useFetchAlertingEpisodesQuery({ + pageSize, + services: { dataViews, http, expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.hasNextPage).toBe(true); + }); + }); + + it('should be disabled when data view is not available', () => { + mockUseAlertingEpisodesDataView.mockReturnValueOnce(undefined); + const pageSize = 10; + + const { result } = renderHook( + () => + useFetchAlertingEpisodesQuery({ + pageSize, + services: { dataViews, http, expressions: mockExpressions }, + }), + { wrapper } + ); + + expect(result.current.fetchStatus).toBe('idle'); + }); + + it('should return data view along with query result', async () => { + const pageSize = 10; + const totalCount = 50; + + executeEsqlQueryMock.mockResolvedValue({ + rows: [{ total: totalCount }], + } as unknown as Datatable); + fetchAlertingEpisodesMock.mockResolvedValue(mockEpisodesData); + + const { result } = renderHook( + () => + useFetchAlertingEpisodesQuery({ + pageSize, + services: { dataViews, http, expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => result.current.isSuccess); + + expect(result.current.dataView).toBe(mockDataView); + }); + + it('should handle total count of 0', async () => { + const pageSize = 10; + const totalCount = 0; + + executeEsqlQueryMock.mockResolvedValue({ + rows: [{ total: totalCount }], + } as unknown as Datatable); + fetchAlertingEpisodesMock.mockResolvedValue({ + rows: [], + } as unknown as Datatable); + + const { result } = renderHook( + () => + useFetchAlertingEpisodesQuery({ + pageSize, + services: { dataViews, http, expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.data?.pages[0].total).toBe(0); + }); + expect(result.current.hasNextPage).toBe(false); + }); + + it('should handle missing total in count query result', async () => { + const pageSize = 10; + + executeEsqlQueryMock.mockResolvedValue({ + rows: [{}], + } as any); + fetchAlertingEpisodesMock.mockResolvedValue(mockEpisodesData); + + const { result } = renderHook( + () => + useFetchAlertingEpisodesQuery({ + pageSize, + services: { dataViews, http, expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.data?.pages[0].total).toBe(0); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query.ts new file mode 100644 index 0000000000000..6bc46256ec3c5 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query.ts @@ -0,0 +1,76 @@ +/* + * 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 { InfiniteData } from '@kbn/react-query'; +import { useInfiniteQuery, useQueryClient } from '@kbn/react-query'; +import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import { ALERTING_EPISODES_COUNT_QUERY } from '../constants'; +import { queryKeys } from '../query_keys'; +import type { UseAlertingEpisodesDataViewOptions } from './use_alerting_episodes_data_view'; +import { useAlertingEpisodesDataView } from './use_alerting_episodes_data_view'; +import { fetchAlertingEpisodes } from '../apis/fetch_alerting_episodes'; +import { executeEsqlQuery } from '../utils/execute_esql_query'; + +export interface UseFetchAlertingEpisodesQueryOptions { + pageSize: number; + services: UseAlertingEpisodesDataViewOptions['services'] & { + expressions: ExpressionsStart; + }; +} + +/** + * Hook to fetch alerting episodes data with pagination. + * Returns an ad-hoc data view too, constructed from the query columns. + */ +export const useFetchAlertingEpisodesQuery = ({ + pageSize, + services, +}: UseFetchAlertingEpisodesQueryOptions) => { + const dataView = useAlertingEpisodesDataView({ services }); + const queryClient = useQueryClient(); + + const queryKey = queryKeys.list(pageSize); + const query = useInfiniteQuery({ + enabled: dataView != null, + queryKey, + queryFn: async ({ signal: abortSignal, pageParam: beforeTimestamp }) => { + // ES|QL doesn't return the total count of aggregated results, so we have to fetch it + // in a separate query. We fetch it only once on the first page to keep a consistent + // count across pagination, as the count can change between page fetches. + const lastData = + queryClient.getQueryData>(queryKey); + let totalEpisodesCount = lastData?.pages[lastData?.pages.length - 1]?.total; + if (totalEpisodesCount == null) { + const episodesCountResult = await executeEsqlQuery({ + expressions: services.expressions, + query: ALERTING_EPISODES_COUNT_QUERY, + input: null, + abortSignal, + }); + totalEpisodesCount = episodesCountResult.rows[0]?.total ?? 0; + } + if (!totalEpisodesCount) { + return { type: 'datatable' as const, columns: [], rows: [], total: 0 }; + } + const episodes = await fetchAlertingEpisodes({ + abortSignal, + pageSize, + beforeTimestamp, + services, + }); + return { ...episodes, total: totalEpisodesCount }; + }, + getNextPageParam: (lastPage, allPages) => { + return allPages.reduce((acc, page) => acc + page.rows.length, 0) < lastPage.total + ? lastPage.rows[lastPage.rows.length - 1]['@timestamp'] + : undefined; + }, + keepPreviousData: true, + }); + + return { ...query, dataView }; +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/jest.config.js b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/jest.config.js new file mode 100644 index 0000000000000..ae0f2eb1f762e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/jest.config.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../..', + roots: ['/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui'], + setupFilesAfterEnv: [ + '/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/setup_tests.ts', + ], +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/kibana.jsonc b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/kibana.jsonc new file mode 100644 index 0000000000000..d3cdf9ebd365e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-browser", + "id": "@kbn/alerting-v2-episodes-ui", + "owner": "@elastic/response-ops", + "group": "platform", + "visibility": "shared" +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/moon.yml b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/moon.yml new file mode 100644 index 0000000000000..dde70a3366eae --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/moon.yml @@ -0,0 +1,65 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/alerting-v2-episodes-ui' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/alerting-v2-episodes-ui' +layer: unknown +owners: + defaultOwner: '@elastic/response-ops' +toolchains: + default: node +language: typescript +project: + title: '@kbn/alerting-v2-episodes-ui' + description: Moon project for @kbn/alerting-v2-episodes-ui + channel: '' + owner: '@elastic/response-ops' + sourceRoot: x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui +dependsOn: + - '@kbn/core-http-browser' + - '@kbn/data-views-plugin' + - '@kbn/field-formats-plugin' + - '@kbn/i18n' + - '@kbn/alerting-v2-plugin' + - '@kbn/esql-types' + - '@kbn/expressions-plugin' + - '@kbn/core-http-browser-mocks' + - '@kbn/react-query' + - '@kbn/data-plugin' + - '@kbn/discover-utils' +tags: + - shared-browser + - package + - prod + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '**/*.tsx' + - '!target/**/*' +tasks: + jest: + command: node + args: + - '--no-experimental-require-module' + - $workspaceRoot/scripts/jest + - '--config' + - $projectRoot/jest.config.js + options: + runFromWorkspaceRoot: true + inputs: + - '@group(src)' + jestCI: + command: node + args: + - '--no-experimental-require-module' + - $workspaceRoot/scripts/jest + - '--config' + - $projectRoot/jest.config.js + options: + runFromWorkspaceRoot: true + inputs: + - '@group(src)' diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/package.json b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/package.json new file mode 100644 index 0000000000000..fc447c47e44bb --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kbn/alerting-v2-episodes-ui", + "description": "React primitives and client-side logic for Alerting V2 Episodes UIs", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0", + "sideEffects": false +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/query_keys.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/query_keys.ts new file mode 100644 index 0000000000000..af21c9f75d458 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/query_keys.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const queryKeys = { + all: ['alert-episodes'] as const, + list: (pageSize: number) => [...queryKeys.all, 'list', pageSize] as const, +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/setup_tests.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/setup_tests.ts new file mode 100644 index 0000000000000..5bdf388e23c50 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/setup_tests.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable import/no-extraneous-dependencies */ +import '@testing-library/jest-dom'; +import '@emotion/jest'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/tsconfig.json b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/tsconfig.json new file mode 100644 index 0000000000000..ee5bf7cf26fce --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "@kbn/ambient-ui-types", + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/core-http-browser", + "@kbn/data-views-plugin", + "@kbn/field-formats-plugin", + "@kbn/i18n", + "@kbn/alerting-v2-plugin", + "@kbn/esql-types", + "@kbn/expressions-plugin", + "@kbn/core-http-browser-mocks", + "@kbn/react-query", + "@kbn/data-plugin", + "@kbn/discover-utils" + ] +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.test.ts new file mode 100644 index 0000000000000..29c3bb03bdbeb --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.test.ts @@ -0,0 +1,223 @@ +/* + * 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 { of, throwError } from 'rxjs'; +import type { Datatable } from '@kbn/expressions-plugin/public'; +import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks'; +import { executeEsqlQuery } from './execute_esql_query'; + +const mockExpressionsService = expressionsPluginMock.createStartContract(); + +describe('executeEsqlQuery', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should execute an ES|QL query and return the result', async () => { + const mockDatatable: Datatable = { + type: 'datatable', + columns: [ + { id: 'count', name: 'count', meta: { type: 'number' } }, + { id: 'status', name: 'status', meta: { type: 'string' } }, + ], + rows: [ + { count: 100, status: 'active' }, + { count: 50, status: 'inactive' }, + ], + }; + + const mockExecutionContract = { + getData: jest.fn().mockReturnValue(of({ result: mockDatatable, partial: false })), + cancel: jest.fn(), + }; + + mockExpressionsService.execute.mockReturnValue(mockExecutionContract as any); + + const result = await executeEsqlQuery({ + expressions: mockExpressionsService, + query: 'FROM index | STATS count() BY status', + input: null, + }); + + expect(mockExpressionsService.execute).toHaveBeenCalledWith( + "esql 'FROM index | STATS count() BY status'", + null + ); + expect(result).toEqual(mockDatatable); + }); + + it('should pass input to the expression execution', async () => { + const mockDatatable: Datatable = { + type: 'datatable', + columns: [], + rows: [], + }; + + const mockExecutionContract = { + getData: jest.fn().mockReturnValue(of({ result: mockDatatable, partial: false })), + cancel: jest.fn(), + }; + + mockExpressionsService.execute.mockReturnValue(mockExecutionContract as any); + + const input = { timeRange: { from: 'now-15m', to: 'now' } }; + + await executeEsqlQuery({ + expressions: mockExpressionsService, + query: 'FROM logs', + input, + }); + + expect(mockExpressionsService.execute).toHaveBeenCalledWith("esql 'FROM logs'", input); + }); + + it('should handle query with single quotes correctly', async () => { + const mockDatatable: Datatable = { + type: 'datatable', + columns: [], + rows: [], + }; + + const mockExecutionContract = { + getData: jest.fn().mockReturnValue(of({ result: mockDatatable, partial: false })), + cancel: jest.fn(), + }; + + mockExpressionsService.execute.mockReturnValue(mockExecutionContract as any); + + await executeEsqlQuery({ + expressions: mockExpressionsService, + query: "FROM index | WHERE status == 'active'", + input: null, + }); + + expect(mockExpressionsService.execute).toHaveBeenCalledWith( + "esql 'FROM index | WHERE status == 'active''", + null + ); + }); + + it('should throw when result type is error', async () => { + const mockError = new Error('Query execution failed'); + const mockExecutionContract = { + getData: jest + .fn() + .mockReturnValue(of({ result: { type: 'error', error: mockError }, partial: false })), + cancel: jest.fn(), + }; + + mockExpressionsService.execute.mockReturnValue(mockExecutionContract as any); + + await expect( + executeEsqlQuery({ + expressions: mockExpressionsService, + query: 'FROM invalid', + input: null, + }) + ).rejects.toThrow('Query execution failed'); + }); + + it('should handle observable errors', async () => { + const mockError = new Error('Observable error'); + const mockExecutionContract = { + getData: jest.fn().mockReturnValue(throwError(() => mockError)), + cancel: jest.fn(), + }; + + mockExpressionsService.execute.mockReturnValue(mockExecutionContract as any); + + await expect( + executeEsqlQuery({ + expressions: mockExpressionsService, + query: 'FROM index', + input: null, + }) + ).rejects.toThrow('Observable error'); + }); + + describe('abort signal handling', () => { + it('should register abort listener when abortSignal is provided', async () => { + const mockDatatable: Datatable = { + type: 'datatable', + columns: [], + rows: [], + }; + + const mockExecutionContract = { + getData: jest.fn().mockReturnValue(of({ result: mockDatatable, partial: false })), + cancel: jest.fn(), + }; + + mockExpressionsService.execute.mockReturnValue(mockExecutionContract as any); + + const abortController = new AbortController(); + const addEventListenerSpy = jest.spyOn(abortController.signal, 'addEventListener'); + + await executeEsqlQuery({ + expressions: mockExpressionsService, + query: 'FROM index', + input: null, + abortSignal: abortController.signal, + }); + + expect(addEventListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + + it('should cancel execution when abort signal is triggered', async () => { + const mockDatatable: Datatable = { + type: 'datatable', + columns: [], + rows: [], + }; + + const mockExecutionContract = { + getData: jest.fn().mockReturnValue(of({ result: mockDatatable, partial: false })), + cancel: jest.fn(), + }; + + mockExpressionsService.execute.mockReturnValue(mockExecutionContract as any); + + const abortController = new AbortController(); + + const executePromise = executeEsqlQuery({ + expressions: mockExpressionsService, + query: 'FROM index', + input: null, + abortSignal: abortController.signal, + }); + + abortController.abort('User cancelled'); + + await executePromise; + + expect(mockExecutionContract.cancel).toHaveBeenCalledWith('User cancelled'); + }); + + it('should not register listener when abortSignal is not provided', async () => { + const mockDatatable: Datatable = { + type: 'datatable', + columns: [], + rows: [], + }; + + const mockExecutionContract = { + getData: jest.fn().mockReturnValue(of({ result: mockDatatable, partial: false })), + cancel: jest.fn(), + }; + + mockExpressionsService.execute.mockReturnValue(mockExecutionContract as any); + + await executeEsqlQuery({ + expressions: mockExpressionsService, + query: 'FROM index', + input: null, + }); + + expect(mockExecutionContract.cancel).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.ts new file mode 100644 index 0000000000000..7d2b0093876f1 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.ts @@ -0,0 +1,42 @@ +/* + * 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 { Datatable, ExpressionsStart } from '@kbn/expressions-plugin/public'; +import { lastValueFrom, map } from 'rxjs'; + +export interface ExecuteEsqlQueryOptions { + expressions: ExpressionsStart; + query: string; + abortSignal?: AbortSignal; + input: Input; +} + +/** + * Executes an ES|QL query through the expressions plugin, using Discover's `esql` function, + * which also transforms the tabular result into a datatable-ready data structure. + */ +export const executeEsqlQuery = ({ + expressions, + query, + input, + abortSignal, +}: ExecuteEsqlQueryOptions) => { + const executionContract = expressions.execute(`esql '${query}'`, input); + abortSignal?.addEventListener('abort', (e) => { + executionContract.cancel((e.target as AbortSignal)?.reason); + }); + return lastValueFrom( + executionContract.getData().pipe( + map(({ result }) => { + if (result.type === 'error') { + throw result.error; + } + return result; + }) + ) + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/pages_to_datatable_records.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/pages_to_datatable_records.test.ts new file mode 100644 index 0000000000000..59ea0312e76fe --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/pages_to_datatable_records.test.ts @@ -0,0 +1,77 @@ +/* + * 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 { Datatable } from '@kbn/expressions-plugin/common'; +import { pagesToDatatableRecords } from './pages_to_datatable_records'; + +describe('pagesToDatatableRecords', () => { + it('should return an empty array when no pages are provided', () => { + const result = pagesToDatatableRecords(); + expect(result).toEqual([]); + }); + + it('should return an empty array when an empty array of pages is provided', () => { + const result = pagesToDatatableRecords([]); + expect(result).toEqual([]); + }); + + it('should convert a single page with rows into DataTableRecords', () => { + const samplePage = { + type: 'datatable', + columns: [], + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + ], + } as Datatable; + const result = pagesToDatatableRecords([samplePage]); + + expect(result).toEqual([ + { id: '0', raw: { a: 1, b: 2 }, flattened: { a: 1, b: 2 } }, + { id: '1', raw: { a: 3, b: 4 }, flattened: { a: 3, b: 4 } }, + ]); + }); + + it('should flatten and combine rows from multiple pages into DataTableRecords', () => { + const pages = [ + { + type: 'datatable', + columns: [], + rows: [{ x: 10, y: 20 }], + }, + { + type: 'datatable', + columns: [], + rows: [{ x: 30, y: 40 }], + }, + ] as Datatable[]; + const result = pagesToDatatableRecords(pages); + + expect(result).toEqual([ + { id: '0', raw: { x: 10, y: 20 }, flattened: { x: 10, y: 20 } }, + { id: '1', raw: { x: 30, y: 40 }, flattened: { x: 30, y: 40 } }, + ]); + }); + + it('should handle pages with no rows and skip them', () => { + const pages = [ + { + type: 'datatable', + columns: [], + rows: [], + }, + { + type: 'datatable', + columns: [], + rows: [{ key: 'value' }], + }, + ] as Datatable[]; + const result = pagesToDatatableRecords(pages); + + expect(result).toEqual([{ id: '0', raw: { key: 'value' }, flattened: { key: 'value' } }]); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/pages_to_datatable_records.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/pages_to_datatable_records.ts new file mode 100644 index 0000000000000..549c9f900e1ee --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/pages_to_datatable_records.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DataTableRecord } from '@kbn/discover-utils'; +import type { Datatable } from '@kbn/expressions-plugin/common'; + +/** + * Converts a list of paginated rows into a flat list of `DataTableRecord`s. + */ +export const pagesToDatatableRecords = (pages?: Datatable[]) => { + return ( + pages + ?.flatMap((page) => page.rows) + .map((row, idx) => { + const record: DataTableRecord = { + id: String(idx), + raw: row, + flattened: row, + }; + + return record; + }) ?? [] + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/.storybook/main.js b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/.storybook/main.js new file mode 100644 index 0000000000000..86b48c32f103e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/.storybook/main.js @@ -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. + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/README.md b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/README.md new file mode 100644 index 0000000000000..e3c5a3b2a7b8a --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/README.md @@ -0,0 +1,186 @@ +# @kbn/alerting-v2-rule-form + +This package provides UI components for creating and editing v2 alerting rules with ES|QL queries. + +## Quick Start + +### For Discover Integration + +Use `DynamicRuleFormFlyout` when the form needs to react to external query changes. The query editor is hidden since the query is controlled externally: + +```tsx +import { DynamicRuleFormFlyout } from '@kbn/alerting-v2-rule-form'; + +function MyComponent({ services, query, onClose }) { + return ( + + ); +} +``` + +### For Plugin Integration + +Use `StandaloneRuleFormFlyout` for a classic flyout where the user controls everything, including the ES|QL query via a built-in editor: + +```tsx +import { StandaloneRuleFormFlyout } from '@kbn/alerting-v2-rule-form'; + +function MyComponent({ services, initialQuery, onClose }) { + return ( + + ); +} +``` + +### For Page Integration with Internal Submission + +Use `StandaloneRuleForm` with `includeSubmission` to let the form handle the API call internally: + +```tsx +import { StandaloneRuleForm } from '@kbn/alerting-v2-rule-form'; + +function CreateRulePage({ services, onSuccess, onCancel }) { + return ( + + ); +} +``` + +## Architecture + +The package uses a composable architecture with multiple layers: + +| Export | Query Editor | Use Case | +|--------|--------------|----------| +| `DynamicRuleFormFlyout` | Hidden | Complete flyout that syncs with external query changes (Discover) | +| `StandaloneRuleFormFlyout` | Visible | Complete flyout with static initialization and editable query (plugins) | +| `RuleFormFlyout` + `DynamicRuleForm` | Hidden | Composable pattern for custom flyout behavior | +| `RuleFormFlyout` + `StandaloneRuleForm` | Visible | Composable pattern for custom flyout behavior | +| `StandaloneRuleForm` with `includeSubmission` | Visible | Standalone form that handles API call internally | + +## Composable Pattern + +For advanced customization, use the composable pattern with the base `RuleFormFlyout`: + +### Dynamic Form (Syncs with Props) + +```tsx +import { RuleFormFlyout, DynamicRuleForm } from '@kbn/alerting-v2-rule-form'; + +function DiscoverRuleFlyout({ services, query, onClose, onSubmit }) { + return ( + + + + ); +} +``` + +The `DynamicRuleForm` uses react-hook-form's `values` prop with `resetOptions: { keepDirtyValues: true }` to: +- Automatically sync form state when `query` prop changes +- Preserve user-modified fields (dirty values) during sync + +### Standalone Form (Static Initialization) + +```tsx +import { RuleFormFlyout, StandaloneRuleForm } from '@kbn/alerting-v2-rule-form'; + +function PluginRuleFlyout({ services, initialQuery, onClose, onSubmit }) { + return ( + + + + ); +} +``` + +The `StandaloneRuleForm` uses react-hook-form's `defaultValues` for static initialization - prop changes after mount are ignored. + +## FormValues Type + +```typescript +interface FormValues { + kind: RuleKind; + metadata: { + name: string; + enabled: boolean; + description?: string; + owner?: string; + labels?: string[]; + }; + timeField: string; + schedule: { + every: string; // Duration string like '5m', '1h' + lookback: string; // Duration string + }; + evaluation: { + query: { + base: string; // The ES|QL query + }; + }; + grouping?: { + fields: string[]; // Columns to group alerts by + }; +} +``` + +## Required Services + +All flyout components require: + +| Service | Description | +|---------|-------------| +| `http` | Core HTTP service for rule creation API | +| `data` | Data plugin for query column fetching | +| `dataViews` | Data views plugin for time field options | +| `notifications` | For success/error toasts (required for flyouts, optional for forms when using `includeSubmission`) | + +## Development + +### Storybook + +This package includes Storybook stories for visual development and testing. + +```bash +yarn storybook alerting_v2_rule_form +``` + +Stories are located in `flyout/__stories__/`. diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/__stories__/rule_form_flyout.stories.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/__stories__/rule_form_flyout.stories.tsx new file mode 100644 index 0000000000000..be9799779f48e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/__stories__/rule_form_flyout.stories.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { DynamicRuleFormFlyout } from '../dynamic_rule_form_flyout'; +import { StandaloneRuleFormFlyout } from '../standalone_rule_form_flyout'; +import { RuleFormFlyout } from '../rule_form_flyout'; +import { DynamicRuleForm } from '../../form/dynamic_rule_form'; +import { StandaloneRuleForm } from '../../form/standalone_rule_form'; +import type { RuleFormServices } from '../../form/contexts/rule_form_context'; + +const mockServices = { + http: { + post: async (path: string, options: any) => { + action('http.post')(path, options); + return { id: 'mock-rule-id', metadata: { name: 'Mock Rule' } }; + }, + } as any, + notifications: { + toasts: { + addSuccess: action('toast.success'), + addDanger: action('toast.danger'), + }, + } as any, + data: { + search: { + search: () => ({ + subscribe: () => ({ unsubscribe: () => {} }), + }), + }, + } as any, + dataViews: { + getIdsWithTitle: async () => [], + get: async () => ({ + fields: { + getByType: () => [{ name: '@timestamp', type: 'date' }], + }, + getIndexPattern: () => 'logs-*', + timeFieldName: '@timestamp', + }), + create: async () => ({ + fields: { + getByType: () => [{ name: '@timestamp', type: 'date' }], + }, + getIndexPattern: () => 'logs-*', + timeFieldName: '@timestamp', + }), + } as any, + application: { + currentAppId$: { + subscribe: () => ({ unsubscribe: () => {} }), + }, + } as any, + lens: { + EmbeddableComponent: () => null, + stateHelperApi: () => ({}), + } as any, +}; + +const mockFormServices: RuleFormServices = { + http: mockServices.http, + data: mockServices.data, + dataViews: mockServices.dataViews, + application: mockServices.application, + notifications: mockServices.notifications, + lens: mockServices.lens, +}; + +// ============================================================================= +// Pre-composed Flyouts (Recommended) +// ============================================================================= + +const dynamicMeta: Meta = { + title: 'Alerting V2/Pre-composed Flyouts', + component: DynamicRuleFormFlyout, + parameters: { + layout: 'fullscreen', + }, +}; + +export default dynamicMeta; + +type DynamicStory = StoryObj; +type StandaloneStory = StoryObj; + +/** + * DynamicRuleFormFlyout - For Discover integration + * Syncs with external query changes while preserving user input. + * Time field is auto-selected from available date fields. + */ +export const Dynamic: DynamicStory = { + args: { + services: mockServices, + query: 'FROM logs-* | WHERE @timestamp > NOW() - 5m | STATS count = COUNT(*) BY host.name', + push: true, + onClose: action('onClose'), + }, +}; + +/** + * StandaloneRuleFormFlyout - For plugin integration + * Static initialization, ignores prop changes after mount. + * Time field is auto-selected from available date fields. + */ +export const Standalone: StandaloneStory = { + render: () => ( + + ), +}; + +/** + * Dynamic flyout with syntactically invalid query + * Form validates ES|QL syntax automatically + */ +export const WithSyntaxError: DynamicStory = { + args: { + services: mockServices, + query: 'FROM |', + push: true, + onClose: action('onClose'), + }, +}; + +// ============================================================================= +// Composable Pattern (Advanced) +// Use RuleFormFlyout as a presentation wrapper with your own form +// ============================================================================= + +type ComposableStory = StoryObj; + +/** + * Composable: RuleFormFlyout is a pure presentation wrapper. + * You provide the form component and control onSubmit directly. + */ +export const ComposableDynamic: ComposableStory = { + render: () => ( + + + + ), +}; + +/** + * Composable: Standalone form with custom flyout wrapper + */ +export const ComposableStandalone: ComposableStory = { + render: () => ( + + + + ), +}; + +/** + * Overlay mode (non-push flyout) + */ +export const OverlayMode: ComposableStory = { + render: () => ( + + + + ), +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/dynamic_rule_form_flyout.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/dynamic_rule_form_flyout.tsx new file mode 100644 index 0000000000000..b6c73e5b0ee20 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/dynamic_rule_form_flyout.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import { RuleFormFlyout } from './rule_form_flyout'; +import { DynamicRuleForm } from '../form/dynamic_rule_form'; +import { useCreateRule } from '../form/hooks/use_create_rule'; +import type { FormValues } from '../form/types'; +import type { RuleFormServices } from '../form/contexts'; + +export interface DynamicRuleFormFlyoutProps { + /** Whether to use push flyout or overlay */ + push?: boolean; + /** Callback when flyout is closed */ + onClose?: () => void; + /** The query that drives form values - changes will sync to form state */ + query: string; + /** Required services */ + services: RuleFormServices; +} + +/** + * Pre-composed flyout with DynamicRuleForm. + * + * Use this for Discover integration where the form needs to react to external + * query changes while preserving user-modified fields. + * + * The flyout manages its own submission via useCreateRule so it can control + * the loading state of its footer buttons. The time field is automatically + * derived from the query's available date fields. + */ +const DynamicRuleFormFlyoutInner = ({ + push, + onClose, + query, + services, +}: DynamicRuleFormFlyoutProps) => { + const { createRule, isLoading } = useCreateRule({ + http: services.http, + notifications: services.notifications, + }); + + const handleSubmit = (values: FormValues) => { + createRule(values, { onSuccess: onClose }); + }; + + return ( + + + + ); +}; + +export const DynamicRuleFormFlyout = (props: DynamicRuleFormFlyoutProps) => { + const queryClient = useMemo(() => new QueryClient(), []); + return ( + + + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/error_callout.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/error_callout.test.tsx new file mode 100644 index 0000000000000..1d76fb064324e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/error_callout.test.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { useForm, FormProvider } from 'react-hook-form'; +import type { FieldErrors } from 'react-hook-form'; +import type { FormValues } from '../form/types'; +import { ErrorCallOut } from './error_callout'; + +// Wrapper component that provides form context with configurable state +const TestWrapper = ({ + errors = {}, + isSubmitted = false, + children, +}: { + errors?: FieldErrors; + isSubmitted?: boolean; + children: React.ReactNode; +}) => { + const methods = useForm({ + defaultValues: { + kind: 'alert', + metadata: { name: '', enabled: false }, + timeField: '', + schedule: { every: '', lookback: '' }, + evaluation: { query: { base: '' } }, + }, + }); + + // Override formState with test values by spreading + const methodsWithOverrides = { + ...methods, + formState: { + ...methods.formState, + errors, + isSubmitted, + }, + }; + + return {children}; +}; + +describe('ErrorCallOut', () => { + // Create mock errors - using 'as' cast since we're testing error display, not field names + const createErrors = ( + errorMap: Record + ): FieldErrors => { + return errorMap as FieldErrors; + }; + + describe('when form is not submitted', () => { + it('returns null even when there are errors', () => { + const errors = createErrors({ + metadata: { message: 'Name is required' }, + }); + + const { container } = render( + + + + ); + + expect(container.querySelector('.euiCallOut')).toBeNull(); + }); + }); + + describe('when form is submitted', () => { + it('returns null when there are no errors', () => { + const { container } = render( + + + + ); + + expect(container.querySelector('.euiCallOut')).toBeNull(); + }); + + it('displays the error callout with a single error message', () => { + const errors = createErrors({ + metadata: { message: 'Name is required' }, + }); + + render( + + + + ); + + expect(screen.getByText('Please address the highlighted errors.')).toBeInTheDocument(); + expect(screen.getByText('Name is required')).toBeInTheDocument(); + }); + + it('displays multiple error messages', () => { + const errors = createErrors({ + metadata: { message: 'Name is required' }, + evaluation: { message: 'Query is invalid' }, + timeField: { message: 'Time field is required' }, + }); + + render( + + + + ); + + expect(screen.getByText('Name is required')).toBeInTheDocument(); + expect(screen.getByText('Query is invalid')).toBeInTheDocument(); + expect(screen.getByText('Time field is required')).toBeInTheDocument(); + }); + + it('filters out errors without messages', () => { + const errors = createErrors({ + metadata: { message: 'Name is required' }, + evaluation: { message: undefined }, + timeField: { message: '' }, + }); + + render( + + + + ); + + expect(screen.getByText('Name is required')).toBeInTheDocument(); + + // Should only have one list item (empty messages are filtered out) + const listItems = screen.getAllByRole('listitem'); + expect(listItems).toHaveLength(1); + }); + + it('renders the callout with danger color', () => { + const errors = createErrors({ + metadata: { message: 'Name is required' }, + }); + + const { container } = render( + + + + ); + + const callout = container.querySelector('.euiCallOut'); + expect(callout).toHaveClass('euiCallOut--danger'); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/error_callout.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/error_callout.tsx new file mode 100644 index 0000000000000..3a1ae100632cf --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/error_callout.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { useFormContext, type FieldErrors } from 'react-hook-form'; +import type { FormValues } from '../form/types'; + +/** + * Recursively extracts all error messages from a nested FieldErrors object. + * Handles both flat errors (e.g., { name: { message: "..." } }) + * and nested errors (e.g., { evaluation: { query: { base: { message: "..." } } } }) + */ +const extractErrorMessages = (errors: FieldErrors): string[] => { + const messages: string[] = []; + + for (const value of Object.values(errors)) { + if (!value) continue; + + // If this level has a message, it's a leaf error node + if (typeof value.message === 'string' && value.message.length > 0) { + messages.push(value.message); + } else if (typeof value === 'object') { + // Recurse into nested objects (excluding known FieldError properties) + messages.push(...extractErrorMessages(value as FieldErrors)); + } + } + + return messages; +}; + +export const ErrorCallOut = () => { + const { + formState: { errors, isSubmitted }, + } = useFormContext(); + + const shouldShowCallout = isSubmitted && Object.keys(errors).length > 0; + + if (!shouldShowCallout) { + return null; + } + + return ( + <> + +
    + {extractErrorMessages(errors).map((error) => ( +
  • {error}
  • + ))} +
+
+ + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/index.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/index.tsx new file mode 100644 index 0000000000000..0bffa8a7b557d --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/index.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import type { RuleFormFlyoutProps } from './rule_form_flyout'; +import type { DynamicRuleFormFlyoutProps } from './dynamic_rule_form_flyout'; +import type { StandaloneRuleFormFlyoutProps } from './standalone_rule_form_flyout'; + +// Lazy load flyout components +const LazyRuleFormFlyout = React.lazy(() => + import('./rule_form_flyout').then((module) => ({ + default: module.RuleFormFlyout, + })) +); + +const LazyDynamicRuleFormFlyout = React.lazy(() => + import('./dynamic_rule_form_flyout').then((module) => ({ + default: module.DynamicRuleFormFlyout, + })) +); + +const LazyStandaloneRuleFormFlyout = React.lazy(() => + import('./standalone_rule_form_flyout').then((module) => ({ + default: module.StandaloneRuleFormFlyout, + })) +); + +// Export lazy components directly for consumers who need full control over Suspense +export { LazyDynamicRuleFormFlyout, LazyStandaloneRuleFormFlyout, LazyRuleFormFlyout }; + +/** Base flyout wrapper - use with DynamicRuleForm or StandaloneRuleForm as children */ +export const RuleFormFlyout = (props: RuleFormFlyoutProps) => ( + }> + + +); + +/** Pre-composed flyout for Discover integration - syncs with external query changes */ +export const DynamicRuleFormFlyout = (props: DynamicRuleFormFlyoutProps) => ( + }> + + +); + +/** Pre-composed flyout for classic experience - static initialization */ +export const StandaloneRuleFormFlyout = (props: StandaloneRuleFormFlyoutProps) => ( + }> + + +); + +// Export types +export type { RuleFormFlyoutProps } from './rule_form_flyout'; +export type { DynamicRuleFormFlyoutProps } from './dynamic_rule_form_flyout'; +export type { StandaloneRuleFormFlyoutProps } from './standalone_rule_form_flyout'; +export type * from '../form/types'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/rule_form_flyout.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/rule_form_flyout.tsx new file mode 100644 index 0000000000000..30dbf1a2597e4 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/rule_form_flyout.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiButton, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, +} from '@elastic/eui'; +import { RULE_FORM_ID } from '../form/constants'; + +const FLYOUT_TITLE_ID = 'ruleV2FormFlyoutTitle'; + +export interface RuleFormFlyoutProps { + push?: boolean; + onClose?: () => void; + isLoading?: boolean; + children: React.ReactNode; +} + +/** + * Base flyout wrapper - a pure presentation component. + * + * Use DynamicRuleFormFlyout or StandaloneRuleFormFlyout for pre-composed + * flyouts that handle form submission and state management. + */ +export const RuleFormFlyout = ({ + push = true, + onClose, + isLoading = false, + children, +}: RuleFormFlyoutProps) => { + return ( + {})} + aria-labelledby={FLYOUT_TITLE_ID} + size="l" + maxWidth={600} + > + + +

+ +

+
+
+ {children} + + + + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/standalone_rule_form_flyout.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/standalone_rule_form_flyout.tsx new file mode 100644 index 0000000000000..7a94afe04df6e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/standalone_rule_form_flyout.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import { RuleFormFlyout } from './rule_form_flyout'; +import { StandaloneRuleForm } from '../form/standalone_rule_form'; +import { useCreateRule } from '../form/hooks/use_create_rule'; +import type { FormValues } from '../form/types'; +import type { RuleFormServices } from '../form/contexts'; + +export interface StandaloneRuleFormFlyoutProps { + /** Whether to use push flyout or overlay */ + push?: boolean; + /** Callback when flyout is closed */ + onClose?: () => void; + /** Initial query for the rule (only used on mount) */ + query: string; + /** Required services */ + services: RuleFormServices; +} + +/** + * Pre-composed flyout with StandaloneRuleForm. + * + * Use this for a classic flyout experience where the user controls everything + * from the form after initial mount. External prop changes are ignored. + * + * The flyout manages its own submission via useCreateRule so it can control + * the loading state of its footer buttons. Time field is auto-selected by + * TimeFieldSelect based on available date fields. + */ +const StandaloneRuleFormFlyoutInner = ({ + push, + onClose, + query, + services, +}: StandaloneRuleFormFlyoutProps) => { + const { createRule, isLoading } = useCreateRule({ + http: services.http, + notifications: services.notifications, + }); + + const handleSubmit = (values: FormValues) => { + createRule(values, { onSuccess: onClose }); + }; + + return ( + + + + ); +}; + +export const StandaloneRuleFormFlyout = (props: StandaloneRuleFormFlyoutProps) => { + const queryClient = useMemo(() => new QueryClient(), []); + return ( + + + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/components/edit_mode_toggle.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/components/edit_mode_toggle.test.tsx new file mode 100644 index 0000000000000..2766179081487 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/components/edit_mode_toggle.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EditModeToggle } from './edit_mode_toggle'; + +describe('EditModeToggle', () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders Form and YAML buttons', () => { + render(); + + expect(screen.getByText('Form')).toBeInTheDocument(); + expect(screen.getByText('YAML')).toBeInTheDocument(); + }); + + it('shows Form as selected when editMode is form', () => { + render(); + + const formButton = screen.getByText('Form').closest('button'); + expect(formButton).toHaveClass('euiButtonGroupButton-isSelected'); + }); + + it('shows YAML as selected when editMode is yaml', () => { + render(); + + const yamlButton = screen.getByText('YAML').closest('button'); + expect(yamlButton).toHaveClass('euiButtonGroupButton-isSelected'); + }); + + it('calls onChange with "yaml" when YAML button is clicked', async () => { + render(); + + await userEvent.click(screen.getByText('YAML')); + + expect(mockOnChange).toHaveBeenCalledWith('yaml'); + }); + + it('calls onChange with "form" when Form button is clicked', async () => { + render(); + + await userEvent.click(screen.getByText('Form')); + + expect(mockOnChange).toHaveBeenCalledWith('form'); + }); + + it('disables buttons when disabled prop is true', () => { + render(); + + const formButton = screen.getByText('Form').closest('button'); + const yamlButton = screen.getByText('YAML').closest('button'); + + expect(formButton).toBeDisabled(); + expect(yamlButton).toBeDisabled(); + }); + + it('does not call onChange when disabled and button is clicked', async () => { + render(); + + await userEvent.click(screen.getByText('YAML')); + + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it('has correct data-test-subj attributes', () => { + render(); + + expect(screen.getByTestId('ruleV2FormEditModeToggle')).toBeInTheDocument(); + expect(screen.getByTestId('ruleV2FormEditModeFormButton')).toBeInTheDocument(); + expect(screen.getByTestId('ruleV2FormEditModeYamlButton')).toBeInTheDocument(); + }); + + it('has accessible legend for screen readers', () => { + render(); + + expect(screen.getByRole('group', { name: 'Edit mode selection' })).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/components/edit_mode_toggle.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/components/edit_mode_toggle.tsx new file mode 100644 index 0000000000000..f1fb167a2f2a9 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/components/edit_mode_toggle.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonGroup } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export type EditMode = 'form' | 'yaml'; + +interface EditModeToggleProps { + editMode: EditMode; + onChange: (mode: EditMode) => void; + disabled?: boolean; +} + +const toggleButtons = [ + { + id: 'form', + label: i18n.translate('xpack.alertingV2.ruleForm.editMode.form', { + defaultMessage: 'Form', + }), + iconType: 'productDashboard', + 'data-test-subj': 'ruleV2FormEditModeFormButton', + }, + { + id: 'yaml', + label: i18n.translate('xpack.alertingV2.ruleForm.editMode.yaml', { + defaultMessage: 'YAML', + }), + iconType: 'code', + 'data-test-subj': 'ruleV2FormEditModeYamlButton', + }, +]; + +export const EditModeToggle = ({ editMode, onChange, disabled }: EditModeToggleProps) => { + const handleChange = (optionId: string) => { + onChange(optionId as EditMode); + }; + + return ( + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/constants.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/constants.ts new file mode 100644 index 0000000000000..9b86c0784b6a8 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/constants.ts @@ -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. + */ + +/** Form ID constant - only one rule form should exist at a time */ +export const RULE_FORM_ID = 'ruleV2Form'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/index.ts new file mode 100644 index 0000000000000..051fa3fb5d6d2 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + RuleFormProvider, + useRuleFormServices, + useRuleFormMeta, + type RuleFormServices, + type RuleFormMeta, + type RuleFormLayout, +} from './rule_form_context'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/rule_form_context.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/rule_form_context.tsx new file mode 100644 index 0000000000000..76f8f0f7c721e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/rule_form_context.tsx @@ -0,0 +1,72 @@ +/* + * 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 { PropsWithChildren } from 'react'; +import React, { createContext, useContext, useMemo } from 'react'; +import type { ApplicationStart, HttpStart, NotificationsStart } from '@kbn/core/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; + +export interface RuleFormServices { + http: HttpStart; + data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; + notifications: NotificationsStart; + application: ApplicationStart; + lens: LensPublicStart; +} + +export type RuleFormLayout = 'page' | 'flyout'; + +export interface RuleFormMeta { + /** Whether the form is rendered on a full page or inside a flyout. */ + layout: RuleFormLayout; +} + +interface RuleFormContextValue { + services: RuleFormServices; + meta: RuleFormMeta; +} + +const DEFAULT_META: RuleFormMeta = { layout: 'page' }; + +const RuleFormContext = createContext(undefined); + +/** + * Provides services and metadata to all rule form descendants. + * + * `meta` defaults to `{ layout: 'page' }` when omitted. + */ +export const RuleFormProvider = ({ + children, + services, + meta = DEFAULT_META, +}: PropsWithChildren<{ services: RuleFormServices; meta?: RuleFormMeta }>) => { + const value = useMemo(() => ({ services, meta }), [services, meta]); + return {children}; +}; + +const useRuleFormContext = (): RuleFormContextValue => { + const context = useContext(RuleFormContext); + if (!context) { + throw new Error('useRuleFormContext must be used within RuleFormProvider'); + } + return context; +}; + +/** Backward-compatible hook that returns only the services object. */ +export const useRuleFormServices = (): RuleFormServices => { + const { services } = useRuleFormContext(); + return services; +}; + +/** Returns the form metadata (layout, etc.). */ +export const useRuleFormMeta = (): RuleFormMeta => { + const { meta } = useRuleFormContext(); + return meta; +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/dynamic_rule_form.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/dynamic_rule_form.test.tsx new file mode 100644 index 0000000000000..a73935474a728 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/dynamic_rule_form.test.tsx @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createQueryClientWrapper, createMockServices } from '../test_utils'; +import { DynamicRuleForm } from './dynamic_rule_form'; +import { RULE_FORM_ID } from './constants'; + +// Mock the yaml-rule-editor to avoid monaco editor setup +jest.mock('@kbn/yaml-rule-editor', () => ({ + YamlRuleEditor: () =>
YAML Editor Mock
, +})); + +// Mock the code-editor to avoid monaco editor setup +jest.mock('@kbn/code-editor', () => ({ + CodeEditor: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => ( +