diff --git a/.buildkite/pipelines/pipeline.kibana-serverless-release.yaml b/.buildkite/pipelines/pipeline.kibana-serverless-release.yaml index 6bceff183662a..0eec18471e2be 100644 --- a/.buildkite/pipelines/pipeline.kibana-serverless-release.yaml +++ b/.buildkite/pipelines/pipeline.kibana-serverless-release.yaml @@ -2,8 +2,11 @@ steps: - label: ":releasethekraken: Release kibana" # https://regex101.com/r/tY52jo/1 if: build.tag =~ /^deploy@\d+\$/ - trigger: gpctl-promote + trigger: gpctl-promote-with-e2e-tests build: env: SERVICE_COMMIT_HASH: "${BUILDKITE_COMMIT:0:12}" REMOTE_SERVICE_CONFIG: https://raw.githubusercontent.com/elastic/serverless-gitops/main/gen/gpctl/kibana/config.yaml + SERVICE: kibana-controller + NAMESPACE: kibana-ci + IMAGE_NAME: kibana-serverless diff --git a/.buildkite/pipelines/quality-gates/pipeline.kibana-tests.yaml b/.buildkite/pipelines/quality-gates/pipeline.kibana-tests.yaml index 27e55dfced9d7..0acdb66f8d5f2 100644 --- a/.buildkite/pipelines/quality-gates/pipeline.kibana-tests.yaml +++ b/.buildkite/pipelines/quality-gates/pipeline.kibana-tests.yaml @@ -1,4 +1,17 @@ +# This pipeline serves as the entry point for your service's quality gates definitions. When +# properly configured, it will be invoked automatically as part of the automated +# promotion process once a new version was rolled out in one of the various cloud stages. +# +# The updated environment is provided via ENVIRONMENT variable. The seedling +# step will branch and execute pipeline snippets at the following location: +# pipeline.tests-qa.yaml +# pipeline.tests-staging.yaml +# pipeline.tests-production.yaml +# +# Docs: https://docs.elastic.dev/serverless/qualitygates + env: + TEAM_CHANNEL: "#kibana-mission-control" ENVIRONMENT: ${ENVIRONMENT?} steps: @@ -8,3 +21,7 @@ steps: command: "make -C /agent run-environment-tests" agents: image: "docker.elastic.co/ci-agent-images/quality-gate-seedling:0.0.2" + +notify: + - slack: "${TEAM_CHANNEL?}" + if: build.branch == "main" && build.state == "failed" diff --git a/.buildkite/pipelines/quality-gates/pipeline.tests-production.yaml b/.buildkite/pipelines/quality-gates/pipeline.tests-production.yaml index f283e2e4f6c9a..1c30a7f734df4 100644 --- a/.buildkite/pipelines/quality-gates/pipeline.tests-production.yaml +++ b/.buildkite/pipelines/quality-gates/pipeline.tests-production.yaml @@ -1,6 +1,6 @@ -env: - TEAM_CHANNEL: "#kibana-serverless-release" - ENVIRONMENT: "production" +# These pipeline steps constitute the quality gate for your service within the production +# environment. Incorporate any necessary additional logic to validate the service's integrity. +# A failure in this pipeline build will prevent further progression to the subsequent stage. steps: - label: ":pipeline::fleet::seedling: Trigger Observability Kibana Tests for ${ENVIRONMENT}" @@ -13,9 +13,17 @@ steps: agents: image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" + - label: ":rocket: Run cp e2e tests" + trigger: "ess-k8s-production-e2e-tests" + build: + message: "${BUILDKITE_MESSAGE}" + env: + REGION_ID: aws-us-east-1 + NAME_PREFIX: ci_test_${SERVICE}-promotion_ + - wait: ~ - label: ":judge::seedling: Trigger Manual Tests Phase" command: "make -C /agent trigger-manual-verification-phase" agents: - image: "docker.elastic.co/ci-agent-images/manual-verification-agent:0.0.1" + image: "docker.elastic.co/ci-agent-images/manual-verification-agent:0.0.2" diff --git a/.buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml b/.buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml index 7bb446f5713f0..e03e986f65833 100644 --- a/.buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml +++ b/.buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml @@ -1,6 +1,6 @@ -env: - TEAM_CHANNEL: "#kibana-serverless-release" - ENVIRONMENT: "qa" +# These pipeline steps constitute the quality gate for your service within the QA environment. +# Incorporate any necessary additional logic to validate the service's integrity. A failure in +# this pipeline build will prevent further progression to the subsequent stage. steps: - label: ":pipeline::kibana::seedling: Trigger Kibana Tests for ${ENVIRONMENT}" @@ -23,9 +23,17 @@ steps: agents: image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" + - label: ":rocket: Run cp e2e tests" + trigger: "ess-k8s-qa-e2e-tests-daily" + build: + message: "${BUILDKITE_MESSAGE}" + env: + REGION_ID: aws-eu-west-1 + NAME_PREFIX: ci_test_kibana-promotion_ + - wait: ~ - label: ":judge::seedling: Trigger Manual Tests Phase" command: "make -C /agent trigger-manual-verification-phase" agents: - image: "docker.elastic.co/ci-agent-images/manual-verification-agent:0.0.1" + image: "docker.elastic.co/ci-agent-images/manual-verification-agent:0.0.2" diff --git a/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml b/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml index 2f3e497fc6d31..83bfd0d27e34c 100644 --- a/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml +++ b/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml @@ -1,6 +1,6 @@ -env: - TEAM_CHANNEL: "#kibana-serverless-release" - ENVIRONMENT: "staging" +# These pipeline steps constitute the quality gate for your service within the staging environment. +# Incorporate any necessary additional logic to validate the service's integrity. A failure in +# this pipeline build will prevent further progression to the subsequent stage. steps: - label: ":pipeline::fleet::seedling: Trigger Observability Kibana Tests for ${ENVIRONMENT}" @@ -13,9 +13,17 @@ steps: agents: image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" + - label: ":rocket: Run cp e2e tests" + trigger: "ess-k8s-staging-e2e-tests" + build: + message: "${BUILDKITE_MESSAGE}" + env: + REGION_ID: aws-us-east-1 + NAME_PREFIX: ci_test_kibana-promotion_ + - wait: ~ - label: ":judge::seedling: Trigger Manual Tests Phase" command: "make -C /agent trigger-manual-verification-phase" agents: - image: "docker.elastic.co/ci-agent-images/manual-verification-agent:0.0.1" + image: "docker.elastic.co/ci-agent-images/manual-verification-agent:0.0.2" diff --git a/.github/workflows/create-deploy-tag.yml b/.github/workflows/create-deploy-tag.yml index ec06f8c11b49d..b230f2a929287 100644 --- a/.github/workflows/create-deploy-tag.yml +++ b/.github/workflows/create-deploy-tag.yml @@ -19,7 +19,7 @@ concurrency: jobs: create-deploy-tag: # Temporary, we need a way to limit this to a GitHub team instead of specific users - if: contains('["watson","clintandrewhall","kobelb","lukeelmers","thomasneirynck"]', github.triggering_actor) + if: contains('["watson","clintandrewhall","kobelb","lukeelmers","thomasneirynck","jbudz","mistic","delanni","Ikuni17"]', github.triggering_actor) runs-on: ubuntu-latest permissions: contents: write @@ -63,7 +63,7 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": "A new has been promoted to QA 🎉\n\nOnce promotion is complete, please begin any required manual testing.\n\n*Remember:* Promotion to Staging is currently a manual process and will proceed once the build is signed off in QA." + "text": "Promotion of a new to QA has been initiated 🎉\n\nOnce promotion is complete, please begin any required manual testing.\n\n*Remember:* Promotion to Staging is currently a manual process and will proceed once the build is signed off in QA." } }, { @@ -73,10 +73,6 @@ jobs: "type": "mrkdwn", "text": "*Initiated by:*\n" }, - { - "type": "mrkdwn", - "text": "*Workflow run:*\n" - }, { "type": "mrkdwn", "text": "*Commit:*\n" @@ -107,7 +103,7 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": "*Useful links:*\n\n • \n • " + "text": "*Useful links:*\n\n • \n • \n • \n • \n • " } }, { @@ -153,7 +149,7 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": "Promotion of to QA failed ⛔️" + "text": "Creation of deploy tag on failed ⛔️" } }, { @@ -163,10 +159,6 @@ jobs: "type": "mrkdwn", "text": "*Initiated by:*\n" }, - { - "type": "mrkdwn", - "text": "*Workflow run:*\n" - }, { "type": "mrkdwn", "text": "*Commit:*\n" @@ -177,7 +169,7 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": "*Useful links:*\n\n • " + "text": "*Useful links:*\n\n • \n • " } } ] diff --git a/docs/management/connectors/action-types/opsgenie.asciidoc b/docs/management/connectors/action-types/opsgenie.asciidoc index 453aa8c00b811..817acdfb135d4 100644 --- a/docs/management/connectors/action-types/opsgenie.asciidoc +++ b/docs/management/connectors/action-types/opsgenie.asciidoc @@ -28,34 +28,6 @@ URL:: The Opsgenie URL. For example, https://api.opsgenie.com or https://api.eu. NOTE: If you are using the <> setting, make sure the hostname is added to the allowed hosts. API Key:: The Opsgenie API authentication key for HTTP Basic authentication. For more details about generating Opsgenie API keys, refer to https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/[Opsgenie documentation]. -[float] -[[preconfigured-opsgenie-configuration]] -=== Create preconfigured connectors - -If you are running {kib} on-prem, you can define connectors by -adding `xpack.actions.preconfigured` settings to your `kibana.yml` file. -For example: - -[source,text] --- -xpack.actions.preconfigured: - my-opsgenie: - name: preconfigured-opsgenie-connector-type - actionTypeId: .opsgenie - config: - apiUrl: https://api.opsgenie.com - secrets: - apiKey: apikey --- - -Config defines information for the connector type. - -`apiUrl`:: A string that corresponds to *URL*. - -Secrets defines sensitive information for the connector type. - -`apiKey`:: A string that corresponds to *API Key*. - [float] [[opsgenie-action-configuration]] === Test connectors diff --git a/docs/management/connectors/pre-configured-connectors.asciidoc b/docs/management/connectors/pre-configured-connectors.asciidoc index 3584207a88364..ceaf771a58f9e 100644 --- a/docs/management/connectors/pre-configured-connectors.asciidoc +++ b/docs/management/connectors/pre-configured-connectors.asciidoc @@ -83,9 +83,30 @@ configuration. [float] === Examples +* <> * <> * <> +[float] +[[preconfigured-opsgenie-configuration]] +==== {opsgenie} connectors + +The following example creates an <>: + +[source,text] +-- +xpack.actions.preconfigured: + my-opsgenie: + name: preconfigured-opsgenie-connector-type + actionTypeId: .opsgenie + config: + apiUrl: https://api.opsgenie.com <1> + secrets: + apiKey: apikey <2> +-- +<1> The {opsgenie} URL. +<2> The {opsgenie} API authentication key for HTTP basic authentication. + [float] [[preconfigured-server-log-configuration]] ==== Server log connectors diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index c48483224ec52..9ab4058d35e30 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -252,9 +252,31 @@ Specifies configuration details that are specific to the type of preconfigured c The type of preconfigured connector. For example: `.email`, `.index`, `.opsgenie`, `.server-log`, `.resilient`, `.slack`, and `.webhook`. +`xpack.actions.preconfigured..config.apiUrl`:: +A configuration URL that varies by connector: ++ +-- +* For an <>, specifies the {opsgenie} URL. For example, `https://api.opsgenie.com` or `https://api.eu.opsgenie.com`. + +NOTE: If you are using the `xpack.actions.allowedHosts` setting, make sure the hostname in the URL is added to the allowed hosts. +-- + `xpack.actions.preconfigured..name`:: The name of the preconfigured connector. +`xpack.actions.preconfigured..secrets`:: +Sensitive configuration details, such as username, password, and keys, which are specific to the connector type. ++ +TIP: Sensitive properties, such as passwords, should be stored in the <>. + +`xpack.actions.preconfigured..secrets.apikey`:: +An API key secret that varies by connector: ++ +-- +* For an <>, specifies the {opsgenie} API authentication key for HTTP basic authentication. +-- + + [float] [[alert-settings]] === Alerting settings diff --git a/package.json b/package.json index 06e36a7baf297..a26b2479e9816 100644 --- a/package.json +++ b/package.json @@ -841,6 +841,7 @@ "core-js": "^3.31.0", "cronstrue": "^1.51.0", "css-box-model": "^1.2.1", + "css.escape": "^1.5.1", "cuid": "^2.1.8", "cytoscape": "^3.10.0", "cytoscape-dagre": "^2.2.2", diff --git a/packages/kbn-search-api-panels/types.ts b/packages/kbn-search-api-panels/types.ts index 63edec82c345d..5aba2d7b46bc0 100644 --- a/packages/kbn-search-api-panels/types.ts +++ b/packages/kbn-search-api-panels/types.ts @@ -24,6 +24,8 @@ export interface LanguageDefinitionSnippetArguments { apiKey: string; indexName?: string; cloudId?: string; + ingestPipeline?: string; + extraIngestDocumentValues?: Record; } type CodeSnippet = string | ((args: LanguageDefinitionSnippetArguments) => string); diff --git a/packages/kbn-text-based-editor/index.ts b/packages/kbn-text-based-editor/index.ts index fd7b19839b87f..01f106af6639f 100644 --- a/packages/kbn-text-based-editor/index.ts +++ b/packages/kbn-text-based-editor/index.ts @@ -7,6 +7,7 @@ */ export type { TextBasedLanguagesEditorProps } from './src/text_based_languages_editor'; +export { fetchFieldsFromESQL } from './src/fetch_fields_from_esql'; import { TextBasedLanguagesEditor } from './src/text_based_languages_editor'; // React.lazy support diff --git a/packages/kbn-text-based-editor/src/editor_footer.tsx b/packages/kbn-text-based-editor/src/editor_footer.tsx index 6f1e6cdd0b130..f89a14d06f106 100644 --- a/packages/kbn-text-based-editor/src/editor_footer.tsx +++ b/packages/kbn-text-based-editor/src/editor_footer.tsx @@ -155,6 +155,7 @@ interface EditorFooterProps { detectTimestamp: boolean; onErrorClick: (error: MonacoError) => void; refreshErrors: () => void; + hideRunQueryText?: boolean; } export const EditorFooter = memo(function EditorFooter({ @@ -165,6 +166,7 @@ export const EditorFooter = memo(function EditorFooter({ detectTimestamp, onErrorClick, refreshErrors, + hideRunQueryText, }: EditorFooterProps) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); return ( @@ -235,27 +237,29 @@ export const EditorFooter = memo(function EditorFooter({ - - - - -

- {i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.runQuery', { - defaultMessage: 'Run query', - })} -

-
-
- - {`${COMMAND_KEY} + Enter`} - -
-
+ {!hideRunQueryText && ( + + + + +

+ {i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.runQuery', { + defaultMessage: 'Run query', + })} +

+
+
+ + {`${COMMAND_KEY} + Enter`} + +
+
+ )} ); }); diff --git a/packages/kbn-text-based-editor/src/fetch_fields_from_esql.ts b/packages/kbn-text-based-editor/src/fetch_fields_from_esql.ts index b847e4cb0bb43..7b9ebd66d76c6 100644 --- a/packages/kbn-text-based-editor/src/fetch_fields_from_esql.ts +++ b/packages/kbn-text-based-editor/src/fetch_fields_from_esql.ts @@ -8,7 +8,7 @@ import { pluck } from 'rxjs/operators'; import { lastValueFrom } from 'rxjs'; -import { Query, AggregateQuery } from '@kbn/es-query'; +import { Query, AggregateQuery, TimeRange } from '@kbn/es-query'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { Datatable } from '@kbn/expressions-plugin/public'; import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common'; @@ -20,9 +20,14 @@ interface TextBasedLanguagesErrorResponse { type: 'error'; } -export function fetchFieldsFromESQL(query: Query | AggregateQuery, expressions: ExpressionsStart) { +export function fetchFieldsFromESQL( + query: Query | AggregateQuery, + expressions: ExpressionsStart, + time?: TimeRange +) { return textBasedQueryStateToAstWithValidation({ query, + time, }) .then((ast) => { if (ast) { diff --git a/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx b/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx index 4e3853970d7a2..0be4c38eed749 100644 --- a/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx +++ b/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx @@ -242,4 +242,27 @@ describe('TextBasedLanguagesEditor', () => { ).toBe('1 line'); }); }); + + it('should render the run query text', async () => { + const newProps = { + ...props, + isCodeEditorExpanded: true, + }; + await act(async () => { + const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps })); + expect(component.find('[data-test-subj="TextBasedLangEditor-run-query"]').length).not.toBe(0); + }); + }); + + it('should not render the run query text if the hideRunQueryText prop is set to true', async () => { + const newProps = { + ...props, + isCodeEditorExpanded: true, + hideRunQueryText: true, + }; + await act(async () => { + const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps })); + expect(component.find('[data-test-subj="TextBasedLangEditor-run-query"]').length).toBe(0); + }); + }); }); diff --git a/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx b/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx index 5eb83f625493a..3aada71f81ab0 100644 --- a/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx +++ b/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx @@ -75,6 +75,7 @@ export interface TextBasedLanguagesEditorProps { isDarkMode?: boolean; dataTestSubj?: string; hideMinimizeButton?: boolean; + hideRunQueryText?: boolean; } interface TextBasedEditorDeps { @@ -120,6 +121,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ isDisabled, isDarkMode, hideMinimizeButton, + hideRunQueryText, dataTestSubj, }: TextBasedLanguagesEditorProps) { const { euiTheme } = useEuiTheme(); @@ -781,6 +783,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ onErrorClick={onErrorClick} refreshErrors={onTextLangQuerySubmit} detectTimestamp={detectTimestamp} + hideRunQueryText={hideRunQueryText} /> )} {isCodeEditorExpanded && ( diff --git a/renovate.json b/renovate.json index cab03197e4c42..7d6ccf6a22c88 100644 --- a/renovate.json +++ b/renovate.json @@ -278,6 +278,7 @@ { "groupName": "platform security modules", "matchPackageNames": [ + "css.escape", "node-forge", "formik", "@types/node-forge", @@ -610,4 +611,4 @@ "enabled": true } ] -} +} \ No newline at end of file diff --git a/x-pack/packages/security-solution/features/src/constants.ts b/x-pack/packages/security-solution/features/src/constants.ts index 2054749d0eabb..c92376fd36209 100644 --- a/x-pack/packages/security-solution/features/src/constants.ts +++ b/x-pack/packages/security-solution/features/src/constants.ts @@ -15,6 +15,9 @@ export const ASSISTANT_FEATURE_ID = 'securitySolutionAssistant' as const; // Same as the plugin id defined by Cloud Security Posture export const CLOUD_POSTURE_APP_ID = 'csp' as const; +// Same as the plugin id defined by Defend for containers (cloud_defend) +export const CLOUD_DEFEND_APP_ID = 'cloudDefend' as const; + /** * Id for the notifications alerting type * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function diff --git a/x-pack/packages/security-solution/features/src/security/kibana_features.ts b/x-pack/packages/security-solution/features/src/security/kibana_features.ts index 34252ec1a35be..f4176dfa53719 100644 --- a/x-pack/packages/security-solution/features/src/security/kibana_features.ts +++ b/x-pack/packages/security-solution/features/src/security/kibana_features.ts @@ -18,7 +18,13 @@ import { THRESHOLD_RULE_TYPE_ID, } from '@kbn/securitysolution-rules'; import type { BaseKibanaFeatureConfig } from '../types'; -import { APP_ID, SERVER_APP_ID, LEGACY_NOTIFICATIONS_ID, CLOUD_POSTURE_APP_ID } from '../constants'; +import { + APP_ID, + SERVER_APP_ID, + LEGACY_NOTIFICATIONS_ID, + CLOUD_POSTURE_APP_ID, + CLOUD_DEFEND_APP_ID, +} from '../constants'; import type { SecurityFeatureParams } from './types'; const SECURITY_RULE_TYPES = [ @@ -44,7 +50,7 @@ export const getSecurityBaseKibanaFeature = ({ ), order: 1100, category: DEFAULT_APP_CATEGORIES.security, - app: [APP_ID, CLOUD_POSTURE_APP_ID, 'kibana'], + app: [APP_ID, CLOUD_POSTURE_APP_ID, CLOUD_DEFEND_APP_ID, 'kibana'], catalogue: [APP_ID], management: { insightsAndAlerting: ['triggersActions'], @@ -52,7 +58,7 @@ export const getSecurityBaseKibanaFeature = ({ alerting: SECURITY_RULE_TYPES, privileges: { all: { - app: [APP_ID, CLOUD_POSTURE_APP_ID, 'kibana'], + app: [APP_ID, CLOUD_POSTURE_APP_ID, CLOUD_DEFEND_APP_ID, 'kibana'], catalogue: [APP_ID], api: [ APP_ID, @@ -62,6 +68,8 @@ export const getSecurityBaseKibanaFeature = ({ 'rac', 'cloud-security-posture-all', 'cloud-security-posture-read', + 'cloud-defend-all', + 'cloud-defend-read', ], savedObject: { all: ['alert', ...savedObjects], @@ -81,9 +89,9 @@ export const getSecurityBaseKibanaFeature = ({ ui: ['show', 'crud'], }, read: { - app: [APP_ID, CLOUD_POSTURE_APP_ID, 'kibana'], + app: [APP_ID, CLOUD_POSTURE_APP_ID, CLOUD_DEFEND_APP_ID, 'kibana'], catalogue: [APP_ID], - api: [APP_ID, 'lists-read', 'rac', 'cloud-security-posture-read'], + api: [APP_ID, 'lists-read', 'rac', 'cloud-security-posture-read', 'cloud-defend-read'], savedObject: { all: [], read: [...savedObjects], diff --git a/x-pack/plugins/actions/kibana.jsonc b/x-pack/plugins/actions/kibana.jsonc index 9152e64ba898a..78f66742c2a03 100644 --- a/x-pack/plugins/actions/kibana.jsonc +++ b/x-pack/plugins/actions/kibana.jsonc @@ -21,7 +21,8 @@ "usageCollection", "spaces", "security", - "monitoringCollection" + "monitoringCollection", + "serverless" ], "extraPublicDirs": [ "common" diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 9a620d1452f23..da45fd40cf925 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -64,6 +64,15 @@ const connectorTypeSchema = schema.object({ maxAttempts: schema.maybe(schema.number({ min: MIN_MAX_ATTEMPTS, max: MAX_MAX_ATTEMPTS })), }); +// We leverage enabledActionTypes list by allowing the other plugins to overwrite it by using "setEnabledConnectorTypes" in the plugin setup. +// The list can be overwritten only if it's not already been set in the config. +const enabledConnectorTypesSchema = schema.arrayOf( + schema.oneOf([schema.string(), schema.literal(EnabledActionTypes.Any)]), + { + defaultValue: [AllowedHosts.Any], + } +); + export const configSchema = schema.object({ allowedHosts: schema.arrayOf( schema.oneOf([schema.string({ hostname: true }), schema.literal(AllowedHosts.Any)]), @@ -71,12 +80,7 @@ export const configSchema = schema.object({ defaultValue: [AllowedHosts.Any], } ), - enabledActionTypes: schema.arrayOf( - schema.oneOf([schema.string(), schema.literal(EnabledActionTypes.Any)]), - { - defaultValue: [AllowedHosts.Any], - } - ), + enabledActionTypes: enabledConnectorTypesSchema, preconfiguredAlertHistoryEsIndex: schema.boolean({ defaultValue: false }), preconfigured: schema.recordOf(schema.string(), preconfiguredActionSchema, { defaultValue: {}, @@ -129,6 +133,7 @@ export const configSchema = schema.object({ }); export type ActionsConfig = TypeOf; +export type EnabledConnectorTypes = TypeOf; // It would be nicer to add the proxyBypassHosts / proxyOnlyHosts restriction on // simultaneous usage in the config validator directly, but there's no good way to express diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index ad26114cf7d07..70a2cfd9f8e85 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -31,6 +31,7 @@ const createSetupMock = () => { getCaseConnectorClass: jest.fn(), getActionsHealth: jest.fn(), getActionsConfigurationUtilities: jest.fn(), + setEnabledConnectorTypes: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 66d75da5fd7cf..d3bc3be1a9deb 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -348,6 +348,163 @@ describe('Actions Plugin', () => { expect(pluginSetup.isPreconfiguredConnector('anotherConnectorId')).toEqual(false); }); }); + + describe('setEnabledConnectorTypes (works only on serverless)', () => { + function setup(config: ActionsConfig) { + context = coreMock.createPluginInitializerContext(config); + plugin = new ActionsPlugin(context); + coreSetup = coreMock.createSetup(); + pluginsSetup = { + taskManager: taskManagerMock.createSetup(), + encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(), + licensing: licensingMock.createSetup(), + eventLog: eventLogMock.createSetup(), + usageCollection: usageCollectionPluginMock.createSetupContract(), + features: featuresPluginMock.createSetup(), + serverless: {}, + }; + } + + it('should set connector type enabled', async () => { + setup(getConfig()); + // coreMock.createSetup doesn't support Plugin generics + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup); + const coreStart = coreMock.createStart(); + const pluginsStart = { + licensing: licensingMock.createStart(), + taskManager: taskManagerMock.createStart(), + encryptedSavedObjects: encryptedSavedObjectsMock.createStart(), + eventLog: eventLogMock.createStart(), + }; + const pluginStart = plugin.start(coreStart, pluginsStart); + + pluginSetup.registerType({ + id: '.server-log', + name: 'Server log', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + executor, + }); + pluginSetup.registerType({ + id: '.slack', + name: 'Slack', + minimumLicenseRequired: 'gold', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + executor, + }); + pluginSetup.setEnabledConnectorTypes(['.server-log']); + expect(pluginStart.isActionTypeEnabled('.server-log')).toBeTruthy(); + expect(pluginStart.isActionTypeEnabled('.slack')).toBeFalsy(); + }); + + it('should set all the connector types enabled when null or ["*"] passed', async () => { + setup(getConfig()); + // coreMock.createSetup doesn't support Plugin generics + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup); + const coreStart = coreMock.createStart(); + const pluginsStart = { + licensing: licensingMock.createStart(), + taskManager: taskManagerMock.createStart(), + encryptedSavedObjects: encryptedSavedObjectsMock.createStart(), + eventLog: eventLogMock.createStart(), + }; + const pluginStart = plugin.start(coreStart, pluginsStart); + + pluginSetup.registerType({ + id: '.server-log', + name: 'Server log', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + executor, + }); + pluginSetup.registerType({ + id: '.index', + name: 'Index', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + executor, + }); + pluginSetup.setEnabledConnectorTypes(['*']); + expect(pluginStart.isActionTypeEnabled('.server-log')).toBeTruthy(); + expect(pluginStart.isActionTypeEnabled('.index')).toBeTruthy(); + }); + + it('should set all the connector types disabled when [] passed', async () => { + setup(getConfig()); + // coreMock.createSetup doesn't support Plugin generics + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup); + const coreStart = coreMock.createStart(); + const pluginsStart = { + licensing: licensingMock.createStart(), + taskManager: taskManagerMock.createStart(), + encryptedSavedObjects: encryptedSavedObjectsMock.createStart(), + eventLog: eventLogMock.createStart(), + }; + const pluginStart = plugin.start(coreStart, pluginsStart); + + pluginSetup.registerType({ + id: '.server-log', + name: 'Server log', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + executor, + }); + pluginSetup.registerType({ + id: '.index', + name: 'Index', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + executor, + }); + pluginSetup.setEnabledConnectorTypes([]); + expect(pluginStart.isActionTypeEnabled('.server-log')).toBeFalsy(); + expect(pluginStart.isActionTypeEnabled('.index')).toBeFalsy(); + }); + + it('should throw if the enabledActionTypes is already set by the config', async () => { + setup({ ...getConfig(), enabledActionTypes: ['.email'] }); + // coreMock.createSetup doesn't support Plugin generics + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup); + + expect(() => pluginSetup.setEnabledConnectorTypes(['.index'])).toThrow( + "Enabled connector types can be set only if they haven't already been set in the config" + ); + }); + }); }); describe('start()', () => { @@ -396,6 +553,38 @@ describe('Actions Plugin', () => { }; }); + it('should throw when there is an invalid connector type in enabledActionTypes', async () => { + const pluginSetup = await plugin.setup(coreSetup, { + ...pluginsSetup, + encryptedSavedObjects: { + ...pluginsSetup.encryptedSavedObjects, + canEncrypt: true, + }, + serverless: {}, + }); + + pluginSetup.registerType({ + id: '.server-log', + name: 'Server log', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + executor, + }); + + pluginSetup.setEnabledConnectorTypes(['.server-log', 'non-existing']); + + await expect(async () => + plugin.start(coreStart, { ...pluginsStart, serverless: {} }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action type \\"non-existing\\" is not registered."` + ); + }); + describe('getActionsClientWithRequest()', () => { it('should not throw error when ESO plugin has encryption key', async () => { await plugin.setup(coreSetup, { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index b8b88b05049ca..415a9e36a1c01 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -40,7 +40,8 @@ import { } from '@kbn/event-log-plugin/server'; import { MonitoringCollectionSetup } from '@kbn/monitoring-collection-plugin/server'; -import { ActionsConfig, getValidatedConfig } from './config'; +import { ServerlessPluginSetup } from '@kbn/serverless/server'; +import { ActionsConfig, AllowedHosts, EnabledConnectorTypes, getValidatedConfig } from './config'; import { resolveCustomHosts } from './lib/custom_host_settings'; import { ActionsClient } from './actions_client/actions_client'; import { ActionTypeRegistry } from './action_type_registry'; @@ -100,10 +101,8 @@ import { createSubActionConnectorFramework } from './sub_action_framework'; import { IServiceAbstract, SubActionConnectorType } from './sub_action_framework/types'; import { SubActionConnector } from './sub_action_framework/sub_action_connector'; import { CaseConnector } from './sub_action_framework/case'; -import { - type IUnsecuredActionsClient, - UnsecuredActionsClient, -} from './unsecured_actions_client/unsecured_actions_client'; +import type { IUnsecuredActionsClient } from './unsecured_actions_client/unsecured_actions_client'; +import { UnsecuredActionsClient } from './unsecured_actions_client/unsecured_actions_client'; import { createBulkUnsecuredExecutionEnqueuerFunction } from './create_unsecured_execute_function'; import { createSystemConnectors } from './create_system_actions'; @@ -130,6 +129,7 @@ export interface PluginSetupContract { getCaseConnectorClass: () => IServiceAbstract; getActionsHealth: () => { hasPermanentEncryptionKey: boolean }; getActionsConfigurationUtilities: () => ActionsConfigurationUtilities; + setEnabledConnectorTypes: (connectorTypes: EnabledConnectorTypes) => void; } export interface PluginStartContract { @@ -169,6 +169,7 @@ export interface ActionsPluginsSetup { features: FeaturesPluginSetup; spaces?: SpacesPluginSetup; monitoringCollection?: MonitoringCollectionSetup; + serverless?: ServerlessPluginSetup; } export interface ActionsPluginsStart { @@ -178,6 +179,7 @@ export interface ActionsPluginsStart { eventLog: IEventLogClientService; spaces?: SpacesPluginStart; security?: SecurityPluginStart; + serverless?: ServerlessPluginSetup; } const includedHiddenTypes = [ @@ -375,6 +377,20 @@ export class ActionsPlugin implements Plugin actionsConfigUtils, + setEnabledConnectorTypes: (connectorTypes) => { + if ( + !!plugins.serverless && + this.actionsConfig.enabledActionTypes.length === 1 && + this.actionsConfig.enabledActionTypes[0] === AllowedHosts.Any + ) { + this.actionsConfig.enabledActionTypes.pop(); + this.actionsConfig.enabledActionTypes.push(...connectorTypes); + } else { + throw new Error( + "Enabled connector types can be set only if they haven't already been set in the config" + ); + } + }, }; } @@ -542,6 +558,8 @@ export class ActionsPlugin implements Plugin { return this.actionTypeRegistry!.isActionTypeEnabled(id, options); @@ -695,6 +713,19 @@ export class ActionsPlugin implements Plugin { + if ( + !!plugins.serverless && + this.actionsConfig.enabledActionTypes.length > 0 && + this.actionsConfig.enabledActionTypes[0] !== AllowedHosts.Any + ) { + this.actionsConfig.enabledActionTypes.forEach((connectorType) => { + // Throws error if action type doesn't exist + this.actionTypeRegistry?.get(connectorType); + }); + } + }; + public stop() { if (this.licenseState) { this.licenseState.clean(); diff --git a/x-pack/plugins/actions/tsconfig.json b/x-pack/plugins/actions/tsconfig.json index 0f4d2faf03e1a..3beeddef429b2 100644 --- a/x-pack/plugins/actions/tsconfig.json +++ b/x-pack/plugins/actions/tsconfig.json @@ -43,6 +43,7 @@ "@kbn/core-saved-objects-api-server-mocks", "@kbn/core-elasticsearch-server-mocks", "@kbn/core-logging-server-mocks", + "@kbn/serverless" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/apm/server/routes/settings/apm_indices/route.ts b/x-pack/plugins/apm/server/routes/settings/apm_indices/route.ts index 434ae4785ce5a..3698a13a1f2a6 100644 --- a/x-pack/plugins/apm/server/routes/settings/apm_indices/route.ts +++ b/x-pack/plugins/apm/server/routes/settings/apm_indices/route.ts @@ -8,12 +8,12 @@ import * as t from 'io-ts'; import { SavedObject } from '@kbn/core/server'; import type { APMIndices } from '@kbn/apm-data-access-plugin/server'; +import { saveApmIndices } from '@kbn/apm-data-access-plugin/server/saved_objects/apm_indices'; import { createApmServerRoute } from '../../apm_routes/create_apm_server_route'; import { getApmIndexSettings, ApmIndexSettingsResponse, } from './get_apm_indices'; -import { saveApmIndices } from './save_apm_indices'; // get list of apm indices and values const apmIndexSettingsRoute = createApmServerRoute({ diff --git a/x-pack/plugins/apm/server/routes/settings/apm_indices/save_apm_indices.ts b/x-pack/plugins/apm/server/routes/settings/apm_indices/save_apm_indices.ts deleted file mode 100644 index e9d2bd5fbea92..0000000000000 --- a/x-pack/plugins/apm/server/routes/settings/apm_indices/save_apm_indices.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SavedObjectsClientContract } from '@kbn/core/server'; -import type { APMIndices } from '@kbn/apm-data-access-plugin/server'; -import { - APMIndicesSavedObjectBody, - APM_INDEX_SETTINGS_SAVED_OBJECT_ID, - APM_INDEX_SETTINGS_SAVED_OBJECT_TYPE, -} from '@kbn/apm-data-access-plugin/server/saved_objects/apm_indices'; -import { withApmSpan } from '../../../utils/with_apm_span'; - -export function saveApmIndices( - savedObjectsClient: SavedObjectsClientContract, - apmIndices: Partial -) { - return withApmSpan('save_apm_indices', () => - savedObjectsClient.create( - APM_INDEX_SETTINGS_SAVED_OBJECT_TYPE, - { apmIndices: removeEmpty(apmIndices), isSpaceAware: true }, - { id: APM_INDEX_SETTINGS_SAVED_OBJECT_ID, overwrite: true } - ) - ); -} - -// remove empty/undefined values -function removeEmpty(apmIndices: Partial) { - return Object.entries(apmIndices) - .map(([key, value]) => [key, value?.trim()]) - .filter(([_, value]) => !!value) - .reduce((obj, [key, value]) => { - obj[key] = value; - return obj; - }, {} as Record); -} diff --git a/x-pack/plugins/apm_data_access/server/saved_objects/apm_indices.ts b/x-pack/plugins/apm_data_access/server/saved_objects/apm_indices.ts index 7ab90ef0a605c..96b9c31d6b91c 100644 --- a/x-pack/plugins/apm_data_access/server/saved_objects/apm_indices.ts +++ b/x-pack/plugins/apm_data_access/server/saved_objects/apm_indices.ts @@ -11,6 +11,7 @@ import { schema } from '@kbn/config-schema'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { SavedObjectsClientContract } from '@kbn/core/server'; import { updateApmOssIndexPaths } from './migrations/update_apm_oss_index_paths'; +import { APMIndices } from '..'; export const APM_INDEX_SETTINGS_SAVED_OBJECT_TYPE = 'apm-indices'; export const APM_INDEX_SETTINGS_SAVED_OBJECT_ID = 'apm-indices'; @@ -73,6 +74,28 @@ export const apmIndicesSavedObjectDefinition: SavedObjectsType = { }, }; +export function saveApmIndices( + savedObjectsClient: SavedObjectsClientContract, + apmIndices: Partial +) { + return savedObjectsClient.create( + APM_INDEX_SETTINGS_SAVED_OBJECT_TYPE, + { apmIndices: removeEmpty(apmIndices), isSpaceAware: true }, + { id: APM_INDEX_SETTINGS_SAVED_OBJECT_ID, overwrite: true } + ); +} + +// remove empty/undefined values +function removeEmpty(apmIndices: Partial) { + return Object.entries(apmIndices) + .map(([key, value]) => [key, value?.trim()]) + .filter(([_, value]) => !!value) + .reduce((obj, [key, value]) => { + obj[key] = value; + return obj; + }, {} as Record); +} + export async function getApmIndicesSavedObject(savedObjectsClient: SavedObjectsClientContract) { try { const apmIndicesSavedObject = await savedObjectsClient.get>( diff --git a/x-pack/plugins/apm/server/routes/settings/apm_indices/save_apm_indices.test.ts b/x-pack/plugins/apm_data_access/server/saved_objects/save_apm_indices.test.ts similarity index 95% rename from x-pack/plugins/apm/server/routes/settings/apm_indices/save_apm_indices.test.ts rename to x-pack/plugins/apm_data_access/server/saved_objects/save_apm_indices.test.ts index e72282ba6275a..22278e6c16b56 100644 --- a/x-pack/plugins/apm/server/routes/settings/apm_indices/save_apm_indices.test.ts +++ b/x-pack/plugins/apm_data_access/server/saved_objects/save_apm_indices.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { saveApmIndices } from './save_apm_indices'; +import { saveApmIndices } from './apm_indices'; import { SavedObjectsClientContract } from '@kbn/core/server'; describe('saveApmIndices', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_index_pipeline_parameters.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_index_pipeline_parameters.test.ts new file mode 100644 index 0000000000000..9290289a76b72 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_index_pipeline_parameters.test.ts @@ -0,0 +1,34 @@ +/* + * 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 { mockHttpValues } from '../../../__mocks__/kea_logic'; + +import { nextTick } from '@kbn/test-jest-helpers'; + +import { fetchIndexPipelineParams } from './fetch_index_pipeline_parameters'; + +describe('FetchIndexPipelineParametersApiLogic', () => { + const { http } = mockHttpValues; + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('fetchIndexPipelineParams', () => { + it('calls correct api', async () => { + const response = { + 'pipeline-name': {}, + }; + const promise = Promise.resolve(response); + http.get.mockReturnValue(promise); + const result = fetchIndexPipelineParams({ indexName: 'index-name' }); + await nextTick(); + expect(http.get).toHaveBeenCalledWith( + '/internal/enterprise_search/indices/index-name/pipeline_parameters' + ); + await expect(result).resolves.toEqual(response); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_index_pipeline_parameters.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_index_pipeline_parameters.ts new file mode 100644 index 0000000000000..901a40e5c9e23 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_index_pipeline_parameters.ts @@ -0,0 +1,34 @@ +/* + * 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 { IngestPipelineParams } from '../../../../../common/types/connectors'; +import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export interface FetchIndexPipelineParametersArgs { + indexName: string; +} +export type FetchIndexPipelineParametersResponse = IngestPipelineParams; + +export const fetchIndexPipelineParams = async ({ indexName }: FetchIndexPipelineParametersArgs) => { + const route = `/internal/enterprise_search/indices/${indexName}/pipeline_parameters`; + + return await HttpLogic.values.http.get(route); +}; + +export const FetchIndexPipelineParametersApiLogic = createApiLogic( + ['fetch_index_pipeline_params_api_logic'], + fetchIndexPipelineParams, + { + showErrorFlash: false, + } +); + +export type FetchIndexPipelineParametersApiLogicActions = Actions< + FetchIndexPipelineParametersArgs, + FetchIndexPipelineParametersResponse +>; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/getting_started.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/getting_started.tsx index 804dd5f09eee1..5d0a53f93d013 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/getting_started.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/getting_started.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { css } from '@emotion/react'; import dedent from 'dedent'; @@ -29,6 +29,7 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import { SelectClientPanel, LanguageDefinition, + LanguageDefinitionSnippetArguments, LanguageClientPanel, InstallClientPanel, OverviewPanel, @@ -50,30 +51,41 @@ import { IndexViewLogic } from '../../index_view_logic'; import { OverviewLogic } from '../../overview.logic'; import { GenerateApiKeyModal } from '../generate_api_key_modal/modal'; -import { javascriptDefinition } from './languages/javascript'; +import { consoleDefinition } from './languages/console'; +import { curlDefinition } from './languages/curl'; import { languageDefinitions } from './languages/languages'; const DEFAULT_URL = 'https://localhost:9200'; export const APIGettingStarted = () => { const { http } = useValues(HttpLogic); - const { apiKey, isGenerateModalOpen } = useValues(OverviewLogic); - const { openGenerateModal, closeGenerateModal } = useActions(OverviewLogic); + const { apiKey, isGenerateModalOpen, indexPipelineParameters } = useValues(OverviewLogic); + const { fetchIndexPipelineParameters, openGenerateModal, closeGenerateModal } = + useActions(OverviewLogic); const { indexName } = useValues(IndexViewLogic); const { services } = useKibana(); const cloudContext = useCloudDetails(); - const codeArgs = { + useEffect(() => { + fetchIndexPipelineParameters({ indexName }); + }, [indexName]); + + const codeArgs: LanguageDefinitionSnippetArguments = { apiKey, cloudId: cloudContext.cloudId, + extraIngestDocumentValues: { + _extract_binary_content: indexPipelineParameters.extract_binary_content, + _reduce_whitespace: indexPipelineParameters.reduce_whitespace, + _run_ml_inference: indexPipelineParameters.run_ml_inference, + }, indexName, + ingestPipeline: indexPipelineParameters.name, url: cloudContext.elasticsearchUrl || DEFAULT_URL, }; const assetBasePath = http.basePath.prepend(`/plugins/${PLUGIN_ID}/assets/client_libraries/`); - const [selectedLanguage, setSelectedLanguage] = - useState(javascriptDefinition); + const [selectedLanguage, setSelectedLanguage] = useState(curlDefinition); return ( <> {isGenerateModalOpen && ( @@ -344,7 +356,11 @@ export const APIGettingStarted = () => { { 'buildSearchQuery', codeArgs )} - consoleRequest={getConsoleRequest('buildSearchQuery')} + consoleRequest={getLanguageDefinitionCodeSnippet( + consoleDefinition, + 'buildSearchQuery', + codeArgs + )} selectedLanguage={selectedLanguage} setSelectedLanguage={setSelectedLanguage} assetBasePath={assetBasePath} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/console.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/console.ts new file mode 100644 index 0000000000000..9d395b0a68c67 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/console.ts @@ -0,0 +1,37 @@ +/* + * 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 { LanguageDefinition } from '@kbn/search-api-panels'; + +import { ingestKeysToJSON } from './helpers'; + +export const consoleDefinition: Partial = { + buildSearchQuery: ({ indexName }) => `POST /${indexName ?? 'books'}/_search?pretty + { + "query": { + "query_string": { + "query": "snow" + } + } + }`, + ingestData: ({ indexName, ingestPipeline, extraIngestDocumentValues }) => { + const ingestDocumentKeys = ingestPipeline ? ingestKeysToJSON(extraIngestDocumentValues) : ''; + return `POST _bulk?pretty${ingestPipeline ? `&pipeline=${ingestPipeline}` : ''} + { "index" : { "_index" : "${indexName}" } } + {"name": "Snow Crash", "author": "Neal Stephenson", "release_date": "1992-06-01", "page_count": 470${ingestDocumentKeys}} + { "index" : { "_index" : "${indexName}" } } + {"name": "Revelation Space", "author": "Alastair Reynolds", "release_date": "2000-03-15", "page_count": 585${ingestDocumentKeys}} + { "index" : { "_index" : "${indexName}" } } + {"name": "1984", "author": "George Orwell", "release_date": "1985-06-01", "page_count": 328${ingestDocumentKeys}} + { "index" : { "_index" : "${indexName}" } } + {"name": "Fahrenheit 451", "author": "Ray Bradbury", "release_date": "1953-10-15", "page_count": 227${ingestDocumentKeys}} + { "index" : { "_index" : "${indexName}" } } + {"name": "Brave New World", "author": "Aldous Huxley", "release_date": "1932-06-01", "page_count": 268${ingestDocumentKeys}} + { "index" : { "_index" : "${indexName}" } } + {"name": "The Handmaid's Tale", "author": "Margaret Atwood", "release_date": "1985-06-01", "page_count": 311${ingestDocumentKeys}}`; + }, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/curl.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/curl.ts index 45c67a02798ec..8bac32d754064 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/curl.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/curl.ts @@ -10,6 +10,8 @@ import { Languages, LanguageDefinition } from '@kbn/search-api-panels'; import { docLinks } from '../../../../../../shared/doc_links'; +import { ingestKeysToJSON } from './helpers'; + export const curlDefinition: LanguageDefinition = { buildSearchQuery: ({ indexName }) => `curl -X POST "\$\{ES_URL\}/${indexName}/_search?pretty" \\ -H "Authorization: ApiKey "\$\{API_KEY\}"" \\ @@ -33,23 +35,28 @@ export API_KEY="${apiKey}"`, }, iconType: 'curl.svg', id: Languages.CURL, - ingestData: ({ indexName }) => `curl -X POST "\$\{ES_URL\}/_bulk?pretty" \\ + ingestData: ({ indexName, ingestPipeline, extraIngestDocumentValues }) => { + const ingestDocumentKeys = ingestPipeline ? ingestKeysToJSON(extraIngestDocumentValues) : ''; + return `curl -X POST "\$\{ES_URL\}/_bulk?pretty${ + ingestPipeline ? `&pipeline=${ingestPipeline}` : '' + }" \\ -H "Authorization: ApiKey "\$\{API_KEY\}"" \\ -H "Content-Type: application/json" \\ -d' { "index" : { "_index" : "${indexName}" } } -{"name": "Snow Crash", "author": "Neal Stephenson", "release_date": "1992-06-01", "page_count": 470} +{"name": "Snow Crash", "author": "Neal Stephenson", "release_date": "1992-06-01", "page_count": 470${ingestDocumentKeys}} { "index" : { "_index" : "${indexName}" } } -{"name": "Revelation Space", "author": "Alastair Reynolds", "release_date": "2000-03-15", "page_count": 585} +{"name": "Revelation Space", "author": "Alastair Reynolds", "release_date": "2000-03-15", "page_count": 585${ingestDocumentKeys}} { "index" : { "_index" : "${indexName}" } } -{"name": "1984", "author": "George Orwell", "release_date": "1985-06-01", "page_count": 328} +{"name": "1984", "author": "George Orwell", "release_date": "1985-06-01", "page_count": 328${ingestDocumentKeys}} { "index" : { "_index" : "${indexName}" } } -{"name": "Fahrenheit 451", "author": "Ray Bradbury", "release_date": "1953-10-15", "page_count": 227} +{"name": "Fahrenheit 451", "author": "Ray Bradbury", "release_date": "1953-10-15", "page_count": 227${ingestDocumentKeys}} { "index" : { "_index" : "${indexName}" } } -{"name": "Brave New World", "author": "Aldous Huxley", "release_date": "1932-06-01", "page_count": 268} +{"name": "Brave New World", "author": "Aldous Huxley", "release_date": "1932-06-01", "page_count": 268${ingestDocumentKeys}} { "index" : { "_index" : "${indexName}" } } -{"name": "The Handmaid'"'"'s Tale", "author": "Margaret Atwood", "release_date": "1985-06-01", "page_count": 311} -'`, +{"name": "The Handmaid'"'"'s Tale", "author": "Margaret Atwood", "release_date": "1985-06-01", "page_count": 311${ingestDocumentKeys}} +'`; + }, ingestDataIndex: '', installClient: `# if cURL is not already installed on your system # then install it with the package manager of your choice diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/go.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/go.ts index fc3d8226f9a0c..6c504bba26bdc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/go.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/go.ts @@ -10,6 +10,8 @@ import { Languages, LanguageDefinition } from '@kbn/search-api-panels'; import { docLinks } from '../../../../../../shared/doc_links'; +import { ingestKeysToJSON } from './helpers'; + export const goDefinition: LanguageDefinition = { buildSearchQuery: ({ indexName }) => `searchResp, err := es.Search( es.Search.WithContext(context.Background()), @@ -56,27 +58,32 @@ if err != nil { }, iconType: 'go.svg', id: Languages.GO, - ingestData: ({ indexName }) => `buf := bytes.NewBufferString(\` + ingestData: ({ indexName, ingestPipeline, extraIngestDocumentValues }) => { + const ingestDocumentKeys = ingestPipeline ? ingestKeysToJSON(extraIngestDocumentValues) : ''; + return `buf := bytes.NewBufferString(\` {"index":{"_id":"9780553351927"}} -{"name":"Snow Crash","author":"Neal Stephenson","release_date":"1992-06-01","page_count": 470} +{"name":"Snow Crash","author":"Neal Stephenson","release_date":"1992-06-01","page_count": 470${ingestDocumentKeys}} { "index": { "_id": "9780441017225"}} -{"name": "Revelation Space", "author": "Alastair Reynolds", "release_date": "2000-03-15", "page_count": 585} +{"name": "Revelation Space", "author": "Alastair Reynolds", "release_date": "2000-03-15", "page_count": 585${ingestDocumentKeys}} { "index": { "_id": "9780451524935"}} -{"name": "1984", "author": "George Orwell", "release_date": "1985-06-01", "page_count": 328} +{"name": "1984", "author": "George Orwell", "release_date": "1985-06-01", "page_count": 328${ingestDocumentKeys}} { "index": { "_id": "9781451673319"}} -{"name": "Fahrenheit 451", "author": "Ray Bradbury", "release_date": "1953-10-15", "page_count": 227} +{"name": "Fahrenheit 451", "author": "Ray Bradbury", "release_date": "1953-10-15", "page_count": 227${ingestDocumentKeys}} { "index": { "_id": "9780060850524"}} -{"name": "Brave New World", "author": "Aldous Huxley", "release_date": "1932-06-01", "page_count": 268} +{"name": "Brave New World", "author": "Aldous Huxley", "release_date": "1932-06-01", "page_count": 268${ingestDocumentKeys}} { "index": { "_id": "9780385490818"}} -{"name": "The Handmaid's Tale", "author": "Margaret Atwood", "release_date": "1985-06-01", "page_count": 311} +{"name": "The Handmaid's Tale", "author": "Margaret Atwood", "release_date": "1985-06-01", "page_count": 311${ingestDocumentKeys}} \`) ingestResult, err := es.Bulk( bytes.NewReader(buf.Bytes()), - es.Bulk.WithIndex("${indexName}"), + es.Bulk.WithIndex("${indexName}"),${ + ingestPipeline ? `\n es.Bulk.WithPipeline("${ingestPipeline}"),` : '' + } ) -fmt.Println(ingestResult, err)`, +fmt.Println(ingestResult, err)`; + }, ingestDataIndex: '', installClient: 'go get github.com/elastic/go-elasticsearch/v8@latest', name: i18n.translate('xpack.enterpriseSearch.languages.go', { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/helpers.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/helpers.test.ts new file mode 100644 index 0000000000000..df13ea273061e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/helpers.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ingestKeysToJSON, ingestKeysToPHP, ingestKeysToRuby } from './helpers'; + +describe('getting started language helpers', () => { + describe('ingestKeysToJSON', () => { + it('return empty string when given undefined', () => { + expect(ingestKeysToJSON(undefined)).toEqual(''); + }); + it('return json keys with quotes when given expected data', () => { + expect(ingestKeysToJSON({ _foo: true, _bar: false })).toEqual( + ', "_foo": true, "_bar": false' + ); + }); + }); + describe('ingestKeysToPHP', () => { + it('return empty string when given undefined', () => { + expect(ingestKeysToPHP(undefined)).toEqual(''); + }); + it('return json keys with quotes when given expected data', () => { + expect(ingestKeysToPHP({ _foo: true, _bar: false })).toEqual( + `\n '_foo' => true,\n '_bar' => false,` + ); + }); + }); + describe('ingestKeysToRuby', () => { + it('return empty string when given undefined', () => { + expect(ingestKeysToRuby(undefined)).toEqual(''); + }); + it('return json keys with quotes when given expected data', () => { + expect(ingestKeysToRuby({ _foo: true, _bar: false })).toEqual(', _foo: true, _bar: false'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/helpers.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/helpers.ts new file mode 100644 index 0000000000000..5d98779a851b3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/helpers.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LanguageDefinitionSnippetArguments } from '@kbn/search-api-panels'; + +export const ingestKeysToJSON = ( + extraIngestDocumentValues: LanguageDefinitionSnippetArguments['extraIngestDocumentValues'] +) => + extraIngestDocumentValues + ? Object.entries(extraIngestDocumentValues).reduce((result, value) => { + result += `, "${value[0]}": ${value[1]}`; + return result; + }, '') + : ''; + +export const ingestKeysToPHP = ( + extraIngestDocumentValues: LanguageDefinitionSnippetArguments['extraIngestDocumentValues'] +) => + extraIngestDocumentValues + ? Object.entries(extraIngestDocumentValues).reduce((result, value) => { + result += `\n '${value[0]}' => ${value[1]},`; + return result; + }, '') + : ''; + +export const ingestKeysToRuby = ( + extraIngestDocumentValues: LanguageDefinitionSnippetArguments['extraIngestDocumentValues'] +) => + extraIngestDocumentValues + ? Object.entries(extraIngestDocumentValues).reduce((result, value) => { + result += `, ${value[0]}: ${value[1]}`; + return result; + }, '') + : ''; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/javascript.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/javascript.ts index c73461a1aa396..51d7e4ef68625 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/javascript.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/javascript.ts @@ -10,11 +10,13 @@ import { Languages, LanguageDefinition } from '@kbn/search-api-panels'; import { docLinks } from '../../../../../../shared/doc_links'; +import { ingestKeysToJSON } from './helpers'; + export const javascriptDefinition: LanguageDefinition = { buildSearchQuery: ({ indexName }) => `// Let's search! const searchResult = await client.search({ index: '${indexName}', - q: '9HY9SWR' + q: 'snow' }); console.log(searchResult.hits.hits) @@ -35,34 +37,38 @@ const client = new Client({ }, iconType: 'javascript.svg', id: Languages.JAVASCRIPT, - ingestData: ({ indexName }) => `// Sample flight data + ingestData: ({ indexName, ingestPipeline, extraIngestDocumentValues }) => { + const ingestDocumentKeys = ingestPipeline ? ingestKeysToJSON(extraIngestDocumentValues) : ''; + return `// Sample data books const dataset = [ - {'flight': '9HY9SWR', 'price': 841.2656419677076, 'delayed': false}, - {'flight': 'X98CCZO', 'price': 882.9826615595518, 'delayed': false}, - {'flight': 'UFK2WIZ', 'price': 190.6369038508356, 'delayed': true}, + {"name": "Snow Crash", "author": "Neal Stephenson", "release_date": "1992-06-01", "page_count": 470${ingestDocumentKeys}}, + {"name": "Revelation Space", "author": "Alastair Reynolds", "release_date": "2000-03-15", "page_count": 585${ingestDocumentKeys}}, + {"name": "1984", "author": "George Orwell", "release_date": "1985-06-01", "page_count": 328${ingestDocumentKeys}}, + {"name": "Fahrenheit 451", "author": "Ray Bradbury", "release_date": "1953-10-15", "page_count": 227${ingestDocumentKeys}}, + {"name": "Brave New World", "author": "Aldous Huxley", "release_date": "1932-06-01", "page_count": 268${ingestDocumentKeys}}, + {"name": "The Handmaid's Tale", "author": "Margaret Atwood", "release_date": "1985-06-01", "page_count": 311${ingestDocumentKeys}}, ]; // Index with the bulk helper const result = await client.helpers.bulk({ - datasource: dataset, - onDocument (doc) { - return { index: { _index: '${indexName}' }}; - } + datasource: dataset,${ingestPipeline ? `\n pipeline: "${ingestPipeline}",` : ''} + onDocument: (doc) => ({ index: { _index: '${indexName}' }}), }); console.log(result); /** { - total: 3, + total: 6, failed: 0, retry: 0, - successful: 3, + successful: 6, noop: 0, - time: 421, - bytes: 293, + time: 82, + bytes: 1273, aborted: false } -*/`, +*/`; + }, ingestDataIndex: '', installClient: 'npm install @elastic/elasticsearch@8', name: i18n.translate('xpack.enterpriseSearch.languages.javascript', { @@ -80,10 +86,10 @@ console.log(resp); version: { build_flavor: 'default', build_type: 'docker', - build_hash: 'c94b4700cda13820dad5aa74fae6db185ca5c304', - build_date: '2022-10-24T16:54:16.433628434Z', - build_snapshot: false, - lucene_version: '9.4.1', + build_hash: 'ca3dc3a882d76f14d2765906ce3b1cf421948d19', + build_date: '2023-08-28T11:24:16.383660553Z', + build_snapshot: true, + lucene_version: '9.7.0', minimum_wire_compatibility_version: '7.17.0', minimum_index_compatibility_version: '7.0.0' }, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/php.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/php.ts index 51ea055c23ae8..3146ca60af306 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/php.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/php.ts @@ -10,6 +10,8 @@ import { Languages, LanguageDefinition } from '@kbn/search-api-panels'; import { docLinks } from '../../../../../../shared/doc_links'; +import { ingestKeysToPHP } from './helpers'; + export const phpDefinition: LanguageDefinition = { buildSearchQuery: ({ indexName }) => `$params = [ 'index' => '${indexName}', @@ -33,7 +35,9 @@ print_r($response->asArray());`, }, iconType: 'php.svg', id: Languages.PHP, - ingestData: ({ indexName }) => `$params = [ + ingestData: ({ indexName, ingestPipeline, extraIngestDocumentValues }) => { + const ingestDocumentKeys = ingestPipeline ? ingestKeysToPHP(extraIngestDocumentValues) : ''; + return `$params = [${ingestPipeline ? `\n 'pipeline' => '${ingestPipeline}',` : ''} 'body' => [ [ 'index' => [ @@ -45,7 +49,7 @@ print_r($response->asArray());`, 'name' => 'Snow Crash', 'author' => 'Neal Stephenson', 'release_date' => '1992-06-01', - 'page_count' => 470, + 'page_count' => 470,${ingestDocumentKeys} ], [ 'index' => [ @@ -57,7 +61,7 @@ print_r($response->asArray());`, 'name' => 'Revelation Space', 'author' => 'Alastair Reynolds', 'release_date' => '2000-03-15', - 'page_count' => 585, + 'page_count' => 585,${ingestDocumentKeys} ], [ 'index' => [ @@ -69,7 +73,7 @@ print_r($response->asArray());`, 'name' => '1984', 'author' => 'George Orwell', 'release_date' => '1985-06-01', - 'page_count' => 328, + 'page_count' => 328,${ingestDocumentKeys} ], [ 'index' => [ @@ -81,7 +85,7 @@ print_r($response->asArray());`, 'name' => 'Fahrenheit 451', 'author' => 'Ray Bradbury', 'release_date' => '1953-10-15', - 'page_count' => 227, + 'page_count' => 227,${ingestDocumentKeys} ], [ 'index' => [ @@ -93,7 +97,7 @@ print_r($response->asArray());`, 'name' => 'Brave New World', 'author' => 'Aldous Huxley', 'release_date' => '1932-06-01', - 'page_count' => 268, + 'page_count' => 268,${ingestDocumentKeys} ], [ 'index' => [ @@ -102,17 +106,18 @@ print_r($response->asArray());`, ], ], [ - 'name' => 'The Handmaid\'s Tale', + 'name' => 'The Handmaid\\'s Tale', 'author' => 'Margaret Atwood', 'release_date' => '1985-06-01', - 'page_count' => 311, + 'page_count' => 311,${ingestDocumentKeys} ], ], ]; $response = $client->bulk($params); echo $response->getStatusCode(); - echo (string) $response->getBody();`, + echo (string) $response->getBody();`; + }, ingestDataIndex: '', installClient: 'composer require elasticsearch/elasticsearch', name: i18n.translate('xpack.enterpriseSearch.languages.php', { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/python.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/python.ts index 79fb811185f18..24723ba3632da 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/python.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/python.ts @@ -10,6 +10,8 @@ import { Languages, LanguageDefinition } from '@kbn/search-api-panels'; import { docLinks } from '../../../../../../shared/doc_links'; +import { ingestKeysToJSON } from './helpers'; + export const pythonDefinition: LanguageDefinition = { buildSearchQuery: ({ indexName }) => `client.search(index="${indexName}", q="snow")`, configureClient: ({ url, apiKey }) => `from elasticsearch import Elasticsearch @@ -27,22 +29,25 @@ client = Elasticsearch( }, iconType: 'python.svg', id: Languages.PYTHON, - ingestData: ({ indexName }) => `documents = [ + ingestData: ({ indexName, ingestPipeline, extraIngestDocumentValues }) => { + const ingestDocumentKeys = ingestPipeline ? ingestKeysToJSON(extraIngestDocumentValues) : ''; + return `documents = [ { "index": { "_index": "${indexName}", "_id": "9780553351927"}}, - {"name": "Snow Crash", "author": "Neal Stephenson", "release_date": "1992-06-01", "page_count": 470}, + {"name": "Snow Crash", "author": "Neal Stephenson", "release_date": "1992-06-01", "page_count": 470${ingestDocumentKeys}}, { "index": { "_index": "${indexName}", "_id": "9780441017225"}}, - {"name": "Revelation Space", "author": "Alastair Reynolds", "release_date": "2000-03-15", "page_count": 585}, + {"name": "Revelation Space", "author": "Alastair Reynolds", "release_date": "2000-03-15", "page_count": 585${ingestDocumentKeys}}, { "index": { "_index": "${indexName}", "_id": "9780451524935"}}, - {"name": "1984", "author": "George Orwell", "release_date": "1985-06-01", "page_count": 328}, + {"name": "1984", "author": "George Orwell", "release_date": "1985-06-01", "page_count": 328${ingestDocumentKeys}}, { "index": { "_index": "${indexName}", "_id": "9781451673319"}}, - {"name": "Fahrenheit 451", "author": "Ray Bradbury", "release_date": "1953-10-15", "page_count": 227}, + {"name": "Fahrenheit 451", "author": "Ray Bradbury", "release_date": "1953-10-15", "page_count": 227${ingestDocumentKeys}}, { "index": { "_index": "${indexName}", "_id": "9780060850524"}}, - {"name": "Brave New World", "author": "Aldous Huxley", "release_date": "1932-06-01", "page_count": 268}, + {"name": "Brave New World", "author": "Aldous Huxley", "release_date": "1932-06-01", "page_count": 268${ingestDocumentKeys}}, { "index": { "_index": "${indexName}", "_id": "9780385490818"}}, - {"name": "The Handmaid's Tale", "author": "Margaret Atwood", "release_date": "1985-06-01", "page_count": 311}, + {"name": "The Handmaid's Tale", "author": "Margaret Atwood", "release_date": "1985-06-01", "page_count": 311${ingestDocumentKeys}}, ] -client.bulk(operations=documents)`, +client.bulk(operations=documents${ingestPipeline ? `, pipeline="${ingestPipeline}"` : ''})`; + }, ingestDataIndex: '', installClient: `python -m pip install elasticsearch diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/ruby.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/ruby.ts index 779aa3a99f1fb..43a104b0f7a8b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/ruby.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/getting_started/languages/ruby.ts @@ -10,6 +10,8 @@ import { Languages, LanguageDefinition } from '@kbn/search-api-panels'; import { docLinks } from '../../../../../../shared/doc_links'; +import { ingestKeysToRuby } from './helpers'; + export const rubyDefinition: LanguageDefinition = { buildSearchQuery: ({ indexName }) => `client.search(index: '${indexName}', q: 'snow')`, configureClient: ({ url, apiKey, cloudId }) => `client = Elasticsearch::Client.new( @@ -26,15 +28,18 @@ export const rubyDefinition: LanguageDefinition = { }, iconType: 'ruby.svg', id: Languages.RUBY, - ingestData: ({ indexName }) => `documents = [ - { index: { _index: '${indexName}', data: {name: "Snow Crash", "author": "Neal Stephenson", "release_date": "1992-06-01", "page_count": 470} } }, - { index: { _index: '${indexName}', data: {name: "Revelation Space", "author": "Alastair Reynolds", "release_date": "2000-03-15", "page_count": 585} } }, - { index: { _index: '${indexName}', data: {name: "1984", "author": "George Orwell", "release_date": "1985-06-01", "page_count": 328} } }, - { index: { _index: '${indexName}', data: {name: "Fahrenheit 451", "author": "Ray Bradbury", "release_date": "1953-10-15", "page_count": 227} } }, - { index: { _index: '${indexName}', data: {name: "Brave New World", "author": "Aldous Huxley", "release_date": "1932-06-01", "page_count": 268} } }, - { index: { _index: '${indexName}', data: {name: "The Handmaid's Tale", "author": "Margaret Atwood", "release_date": "1985-06-01", "page_count": 311} } } + ingestData: ({ indexName, ingestPipeline, extraIngestDocumentValues }) => { + const ingestDocumentKeys = ingestPipeline ? ingestKeysToRuby(extraIngestDocumentValues) : ''; + return `documents = [ + { index: { _index: '${indexName}', data: {name: "Snow Crash", author: "Neal Stephenson", release_date: "1992-06-01", page_count: 470${ingestDocumentKeys}} } }, + { index: { _index: '${indexName}', data: {name: "Revelation Space", author: "Alastair Reynolds", release_date: "2000-03-15", page_count: 585${ingestDocumentKeys}} } }, + { index: { _index: '${indexName}', data: {name: "1984", author: "George Orwell", release_date: "1985-06-01", page_count: 328${ingestDocumentKeys}} } }, + { index: { _index: '${indexName}', data: {name: "Fahrenheit 451", author: "Ray Bradbury", release_date: "1953-10-15", page_count: 227${ingestDocumentKeys}} } }, + { index: { _index: '${indexName}', data: {name: "Brave New World", author: "Aldous Huxley", release_date: "1932-06-01", page_count: 268${ingestDocumentKeys}} } }, + { index: { _index: '${indexName}', data: {name: "The Handmaid's Tale", author: "Margaret Atwood", release_date: "1985-06-01", page_count: 311${ingestDocumentKeys}} } } ] -client.bulk(body: documents)`, +client.bulk(body: documents${ingestPipeline ? `, pipeline: "${ingestPipeline}"` : ''})`; + }, ingestDataIndex: '', installClient: `$ gem install elasticsearch`, name: i18n.translate('xpack.enterpriseSearch.languages.ruby', { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/overview.logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/overview.logic.ts index 34584d394b93a..dbe0610b4a855 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/overview.logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/overview.logic.ts @@ -7,7 +7,9 @@ import { kea, MakeLogicType } from 'kea'; +import { DEFAULT_PIPELINE_VALUES } from '../../../../../common/constants'; import { Status } from '../../../../../common/types/api'; +import { IngestPipelineParams } from '../../../../../common/types/connectors'; import { KibanaLogic } from '../../../shared/kibana'; import { GenerateApiKeyLogic } from '../../api/generate_api_key/generate_api_key_logic'; @@ -15,6 +17,10 @@ import { CachedFetchIndexApiLogic, CachedFetchIndexApiLogicActions, } from '../../api/index/cached_fetch_index_api_logic'; +import { + FetchIndexPipelineParametersApiLogic, + FetchIndexPipelineParametersApiLogicActions, +} from '../../api/pipelines/fetch_index_pipeline_parameters'; import { SEARCH_INDICES_PATH } from '../../routes'; @@ -22,6 +28,7 @@ interface OverviewLogicActions { apiError: CachedFetchIndexApiLogicActions['apiError']; apiReset: typeof GenerateApiKeyLogic.actions.apiReset; closeGenerateModal: void; + fetchIndexPipelineParameters: FetchIndexPipelineParametersApiLogicActions['makeRequest']; openGenerateModal: void; toggleClientsPopover: void; toggleManageApiKeyPopover: void; @@ -32,6 +39,8 @@ interface OverviewLogicValues { apiKeyData: typeof GenerateApiKeyLogic.values.data; apiKeyStatus: typeof GenerateApiKeyLogic.values.status; indexData: typeof CachedFetchIndexApiLogic.values.indexData; + indexPipelineData: typeof FetchIndexPipelineParametersApiLogic.values.data; + indexPipelineParameters: IngestPipelineParams; isClientsPopoverOpen: boolean; isError: boolean; isGenerateModalOpen: boolean; @@ -48,12 +57,21 @@ export const OverviewLogic = kea ({ @@ -95,6 +113,11 @@ export const OverviewLogic = kea apiKeyStatus === Status.SUCCESS ? apiKeyData.apiKey.encoded : '', ], + indexPipelineParameters: [ + () => [selectors.indexPipelineData], + (indexPipelineData: typeof FetchIndexPipelineParametersApiLogic.values.data) => + indexPipelineData ?? DEFAULT_PIPELINE_VALUES, + ], isError: [() => [selectors.status], (status) => status === Status.ERROR], isLoading: [ () => [selectors.status, selectors.indexData], diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_index_pipeline.test.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_index_pipeline.test.ts new file mode 100644 index 0000000000000..58476ed18b741 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_index_pipeline.test.ts @@ -0,0 +1,164 @@ +/* + * 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 { IScopedClusterClient } from '@kbn/core/server'; + +import { DEFAULT_PIPELINE_VALUES } from '../../../common/constants'; + +import { getIndexPipelineParameters } from './get_index_pipeline'; + +describe('getIndexPipelineParameters', () => { + const defaultMockClient = () => ({ + asCurrentUser: { + get: jest.fn().mockResolvedValue({}), + indices: { + getMapping: jest.fn().mockResolvedValue({}), + }, + ingest: { + getPipeline: jest.fn().mockRejectedValue('Pipeline not found'), + }, + search: jest.fn().mockResolvedValue({ + hits: { + hits: [], + }, + }), + }, + }); + let mockClient = defaultMockClient(); + let client: IScopedClusterClient; + beforeEach(() => { + jest.resetAllMocks(); + + mockClient = defaultMockClient(); + client = mockClient as unknown as IScopedClusterClient; + }); + it('returns default pipeline if custom not found', async () => { + await expect(getIndexPipelineParameters('my-index', client)).resolves.toEqual( + DEFAULT_PIPELINE_VALUES + ); + }); + it('returns connector pipeline params if found', async () => { + mockClient.asCurrentUser.search = jest.fn().mockResolvedValue({ + hits: { + hits: [ + { + _id: 'unit-test', + _source: {}, + }, + ], + }, + }); + mockClient.asCurrentUser.get = jest.fn().mockResolvedValue({ + _id: 'unit-test', + _source: { + pipeline: { + extract_binary_content: false, + name: 'unit-test-pipeline', + reduce_whitespace: true, + run_ml_inference: true, + }, + }, + }); + await expect(getIndexPipelineParameters('my-index', client)).resolves.toEqual({ + extract_binary_content: false, + name: 'unit-test-pipeline', + reduce_whitespace: true, + run_ml_inference: true, + }); + }); + it('returns default pipeline if fetch custom throws', async () => { + mockClient.asCurrentUser.ingest.getPipeline = jest.fn().mockRejectedValue('Boom'); + + await expect(getIndexPipelineParameters('my-index', client)).resolves.toEqual( + DEFAULT_PIPELINE_VALUES + ); + }); + it('returns custom pipeline if found', async () => { + mockClient.asCurrentUser.ingest.getPipeline = jest.fn().mockResolvedValueOnce({ + 'my-index': { + fake: 'ingest-pipeline', + }, + }); + + await expect(getIndexPipelineParameters('my-index', client)).resolves.toEqual({ + extract_binary_content: true, + name: 'my-index', + reduce_whitespace: true, + run_ml_inference: false, + }); + }); + it('returns default connector index pipeline if found in mapping', async () => { + mockClient.asCurrentUser.indices.getMapping = jest.fn().mockResolvedValueOnce({ + '.elastic-connectors-v1': { + mappings: { + _meta: { + pipeline: { + default_extract_binary_content: false, + default_name: 'my-unit-test-index', + default_reduce_whitespace: false, + default_run_ml_inference: true, + }, + }, + }, + }, + }); + + await expect(getIndexPipelineParameters('my-index', client)).resolves.toEqual({ + extract_binary_content: false, + name: 'my-unit-test-index', + reduce_whitespace: false, + run_ml_inference: true, + }); + }); + it('returns connector params with custom pipeline name', async () => { + mockClient.asCurrentUser.indices.getMapping = jest.fn().mockResolvedValueOnce({ + '.elastic-connectors-v1': { + mappings: { + _meta: { + pipeline: { + default_extract_binary_content: false, + default_name: 'my-unit-test-index', + default_reduce_whitespace: false, + default_run_ml_inference: true, + }, + }, + }, + }, + }); + mockClient.asCurrentUser.ingest.getPipeline = jest.fn().mockResolvedValueOnce({ + 'my-index': { + fake: 'ingest-pipeline', + }, + }); + + await expect(getIndexPipelineParameters('my-index', client)).resolves.toEqual({ + extract_binary_content: false, + name: 'my-index', + reduce_whitespace: false, + run_ml_inference: true, + }); + }); + it('returns defaults if get mapping fails with IndexNotFoundException', async () => { + mockClient.asCurrentUser.indices.getMapping = jest.fn().mockRejectedValue({ + meta: { + body: { + error: { + type: 'index_not_found_exception', + }, + }, + }, + }); + + await expect(getIndexPipelineParameters('my-index', client)).resolves.toEqual( + DEFAULT_PIPELINE_VALUES + ); + }); + it('throws if get mapping fails with non-IndexNotFoundException', async () => { + mockClient.asCurrentUser.indices.getMapping = jest.fn().mockRejectedValue('Boom'); + + await expect(getIndexPipelineParameters('my-index', client)).rejects.toEqual('Boom'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_index_pipeline.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_index_pipeline.ts new file mode 100644 index 0000000000000..45813a109de76 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_index_pipeline.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IScopedClusterClient } from '@kbn/core/server'; + +import { IngestPipelineParams } from '../../../common/types/connectors'; +import { fetchConnectorByIndexName } from '../connectors/fetch_connectors'; + +import { getDefaultPipeline } from './get_default_pipeline'; + +export const getIndexPipelineParameters = async ( + indexName: string, + client: IScopedClusterClient +): Promise => { + // Get the default pipeline data and check for a custom pipeline in parallel + // we want to throw the error if getDefaultPipeline() fails so we're not catching it on purpose + const [defaultPipeline, connector, customPipelineResp] = await Promise.all([ + getDefaultPipeline(client), + fetchConnectorByIndexName(client, indexName), + client.asCurrentUser.ingest + .getPipeline({ + id: `${indexName}`, + }) + .catch(() => null), + ]); + if (connector && connector.pipeline) { + return connector.pipeline; + } + let pipelineName = defaultPipeline.name; + + if (customPipelineResp && customPipelineResp[indexName]) { + pipelineName = indexName; + } + + return { + ...defaultPipeline, + name: pipelineName, + }; +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts index b41f391fd66f6..25342217e5192 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts @@ -44,6 +44,7 @@ import { startMlModelDownload } from '../../lib/ml/start_ml_model_download'; import { createIndexPipelineDefinitions } from '../../lib/pipelines/create_pipeline_definitions'; import { deleteIndexPipelines } from '../../lib/pipelines/delete_pipelines'; import { getCustomPipelines } from '../../lib/pipelines/get_custom_pipelines'; +import { getIndexPipelineParameters } from '../../lib/pipelines/get_index_pipeline'; import { getPipeline } from '../../lib/pipelines/get_pipeline'; import { getMlInferencePipelines } from '../../lib/pipelines/ml_inference/get_ml_inference_pipelines'; import { revertCustomPipeline } from '../../lib/pipelines/revert_custom_pipeline'; @@ -354,6 +355,26 @@ export function registerIndexRoutes({ }) ); + router.get( + { + path: '/internal/enterprise_search/indices/{indexName}/pipeline_parameters', + validate: { + params: schema.object({ + indexName: schema.string(), + }), + }, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + const indexName = decodeURIComponent(request.params.indexName); + const { client } = (await context.core).elasticsearch; + const body = await getIndexPipelineParameters(indexName, client); + return response.ok({ + body, + headers: { 'content-type': 'application/json' }, + }); + }) + ); + router.get( { path: '/internal/enterprise_search/indices/{indexName}/ml_inference/pipeline_processors', diff --git a/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx b/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx index 99ec4a3d5ce5f..f4dc33f73176c 100644 --- a/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx +++ b/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx @@ -12,7 +12,7 @@ import { AppMountParameters, Capabilities, CoreStart } from '@kbn/core/public'; import { useHistory, useLocation } from 'react-router-dom'; import { Start as InspectorPublicPluginStart, RequestAdapter } from '@kbn/inspector-plugin/public'; import { NavigationPublicPluginStart as NavigationStart } from '@kbn/navigation-plugin/public'; -import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public'; +import { toMountPoint } from '@kbn/react-kibana-mount'; import { datasourceSelector, hasFieldsSelector } from '../../state_management'; import { GraphSavePolicy, GraphWorkspaceSavedObject, Workspace } from '../../types'; import { AsObservable, Settings, SettingsWorkspaceProps } from '../settings'; @@ -162,12 +162,10 @@ export const WorkspaceTopNavMenu = (props: WorkspaceTopNavMenuProps) => { props.coreStart.overlays.openFlyout( toMountPoint( - wrapWithTheme( - - - , - props.coreStart.theme.theme$ - ) + + + , + { theme: props.coreStart.theme, i18n: props.coreStart.i18n } ), { size: 'm', diff --git a/x-pack/plugins/graph/tsconfig.json b/x-pack/plugins/graph/tsconfig.json index f4dc6a3faaf73..1e8059c99c5d7 100644 --- a/x-pack/plugins/graph/tsconfig.json +++ b/x-pack/plugins/graph/tsconfig.json @@ -46,6 +46,7 @@ "@kbn/content-management-table-list-view-table", "@kbn/content-management-table-list-view", "@kbn/core-ui-settings-browser", + "@kbn/react-kibana-mount", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/observability/public/pages/alert_details/components/alert_summary.tsx b/x-pack/plugins/observability/public/pages/alert_details/components/alert_summary.tsx index a4ca36772217b..bef504ed330ea 100644 --- a/x-pack/plugins/observability/public/pages/alert_details/components/alert_summary.tsx +++ b/x-pack/plugins/observability/public/pages/alert_details/components/alert_summary.tsx @@ -11,7 +11,7 @@ export interface AlertSummaryField { label: ReactNode | string; value: ReactNode | string | number; } -export interface AlertSummaryProps { +interface AlertSummaryProps { alertSummaryFields?: AlertSummaryField[]; } diff --git a/x-pack/plugins/observability/public/pages/overview/components/date_picker/date_picker.tsx b/x-pack/plugins/observability/public/pages/overview/components/date_picker/date_picker.tsx index 1de12b64e6dfb..e3bb21a460067 100644 --- a/x-pack/plugins/observability/public/pages/overview/components/date_picker/date_picker.tsx +++ b/x-pack/plugins/observability/public/pages/overview/components/date_picker/date_picker.tsx @@ -26,7 +26,7 @@ export interface TimePickerTimeDefaults { to: string; } -export interface DatePickerProps { +interface DatePickerProps { rangeFrom?: string; rangeTo?: string; refreshPaused?: boolean; diff --git a/x-pack/plugins/observability_ai_assistant/public/functions/kibana.ts b/x-pack/plugins/observability_ai_assistant/public/functions/kibana.ts index 5ad877b2c2bff..a47acdb02d433 100644 --- a/x-pack/plugins/observability_ai_assistant/public/functions/kibana.ts +++ b/x-pack/plugins/observability_ai_assistant/public/functions/kibana.ts @@ -50,7 +50,7 @@ export function registerKibanaFunction({ description: 'The body of the request', }, }, - required: ['method', 'pathname', 'body'] as const, + required: ['method', 'pathname'] as const, }, }, ({ arguments: { method, pathname, body, query } }, signal) => { diff --git a/x-pack/plugins/observability_onboarding/server/routes/logs/route.ts b/x-pack/plugins/observability_onboarding/server/routes/logs/route.ts index c28c393382930..085f7cece0d8a 100644 --- a/x-pack/plugins/observability_onboarding/server/routes/logs/route.ts +++ b/x-pack/plugins/observability_onboarding/server/routes/logs/route.ts @@ -40,7 +40,7 @@ const installShipperSetupRoute = createObservabilityOnboardingServerRoute({ scriptDownloadUrl: string; elasticAgentVersion: string; }> { - const { core, plugins, kibanaVersion } = resources; + const { core, plugins } = resources; const coreStart = await core.start(); const kibanaUrl = @@ -53,7 +53,7 @@ const installShipperSetupRoute = createObservabilityOnboardingServerRoute({ return { apiEndpoint, scriptDownloadUrl, - elasticAgentVersion: kibanaVersion, + elasticAgentVersion: '8.9.1', }; }, }); diff --git a/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap b/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap index b6e5caaf0d95d..b5486ba5d649d 100644 --- a/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap +++ b/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`PromptPage renders as expected with additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; +exports[`PromptPage renders as expected with additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; -exports[`PromptPage renders as expected without additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; +exports[`PromptPage renders as expected without additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; diff --git a/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap index 27612db490f4b..a752facec0213 100644 --- a/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`UnauthenticatedPage renders as expected 1`] = `"ElasticMockedFonts

We hit an authentication error

Try logging in again, and if the problem persists, contact your system administrator.

"`; +exports[`UnauthenticatedPage renders as expected 1`] = `"ElasticMockedFonts

We hit an authentication error

Try logging in again, and if the problem persists, contact your system administrator.

"`; -exports[`UnauthenticatedPage renders as expected with custom title 1`] = `"My Company NameMockedFonts

We hit an authentication error

Try logging in again, and if the problem persists, contact your system administrator.

"`; +exports[`UnauthenticatedPage renders as expected with custom title 1`] = `"My Company NameMockedFonts

We hit an authentication error

Try logging in again, and if the problem persists, contact your system administrator.

"`; diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap index a6b592367d47b..cc55a03d84555 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ResetSessionPage renders as expected 1`] = `"ElasticMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected 1`] = `"ElasticMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; -exports[`ResetSessionPage renders as expected with custom page title 1`] = `"My Company NameMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected with custom page title 1`] = `"My Company NameMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.tsx index 9cb85f324fab9..85c78ddfcbaec 100644 --- a/x-pack/plugins/security/server/authorization/reset_session_page.tsx +++ b/x-pack/plugins/security/server/authorization/reset_session_page.tsx @@ -15,6 +15,11 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { PromptPage } from '../prompt_page'; +/** + * Static error page (rendered server-side) when user does not have permission to access the requested page. + * + * To trigger this error create a user without any roles and try to login. + */ export function ResetSessionPage({ logoutUrl, buildNumber, diff --git a/x-pack/plugins/security/server/prompt_page.tsx b/x-pack/plugins/security/server/prompt_page.tsx index 31c1f057942de..c0947aed8477b 100644 --- a/x-pack/plugins/security/server/prompt_page.tsx +++ b/x-pack/plugins/security/server/prompt_page.tsx @@ -5,13 +5,8 @@ * 2.0. */ -import { - EuiEmptyPrompt, - EuiPage, - EuiPageBody, - EuiPageContent_Deprecated as EuiPageContent, - EuiProvider, -} from '@elastic/eui'; +import 'css.escape'; // Polyfill required to render `EuiPageTemplate` server-side +import { EuiPageTemplate, EuiProvider } from '@elastic/eui'; // @ts-expect-error no definitions in component folder import { icon as EuiIconWarning } from '@elastic/eui/lib/components/icon/assets/warning'; // @ts-expect-error no definitions in component folder @@ -61,19 +56,15 @@ export function PromptPage({ const content = ( - - - - {title}} - body={body} - actions={actions} - /> - - - + + {title}} + body={body} + actions={actions} + /> + ); @@ -88,8 +79,6 @@ export function PromptPage({ const styleSheetPaths = [ `${regularBundlePath}/kbn-ui-shared-deps-src/${UiSharedDepsSrc.cssDistFilename}`, `${regularBundlePath}/kbn-ui-shared-deps-npm/${UiSharedDepsNpm.lightCssDistFilename('v8')}`, - `${basePath.serverBasePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, - `${basePath.serverBasePath}/ui/legacy_light_theme.css`, ]; return ( diff --git a/x-pack/plugins/security_solution/public/management/links.test.ts b/x-pack/plugins/security_solution/public/management/links.test.ts index 17116530e6467..28d3c727ce2a4 100644 --- a/x-pack/plugins/security_solution/public/management/links.test.ts +++ b/x-pack/plugins/security_solution/public/management/links.test.ts @@ -93,7 +93,8 @@ describe('links', () => { SecurityPageName.hostIsolationExceptions, SecurityPageName.policies, SecurityPageName.responseActionsHistory, - SecurityPageName.trustedApps + SecurityPageName.trustedApps, + SecurityPageName.cloudDefendPolicies ) ); }); @@ -234,7 +235,9 @@ describe('links', () => { const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.policies)); + expect(filteredLinks).toEqual( + getLinksWithout(SecurityPageName.policies, SecurityPageName.cloudDefendPolicies) + ); }); }); diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 309e9a093979b..60c4c93d43fa6 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -235,6 +235,7 @@ export const getManagementFilteredLinks = async ( if (!canReadPolicyManagement) { linksToExclude.push(SecurityPageName.policies); + linksToExclude.push(SecurityPageName.cloudDefendPolicies); } if (!canReadActionsLogManagement) { diff --git a/x-pack/plugins/stack_alerts/common/esql_query_utils.test.ts b/x-pack/plugins/stack_alerts/common/esql_query_utils.test.ts new file mode 100644 index 0000000000000..64aad9c156958 --- /dev/null +++ b/x-pack/plugins/stack_alerts/common/esql_query_utils.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { rowToDocument, toEsQueryHits, transformDatatableToEsqlTable } from './esql_query_utils'; + +describe('ESQL query utils', () => { + describe('rowToDocument', () => { + it('correctly converts ESQL row to document', () => { + expect( + rowToDocument( + [ + { name: '@timestamp', type: 'date' }, + { name: 'ecs.version', type: 'keyword' }, + { name: 'error.code', type: 'keyword' }, + ], + ['2023-07-12T13:32:04.174Z', '1.8.0', null] + ) + ).toEqual({ + '@timestamp': '2023-07-12T13:32:04.174Z', + 'ecs.version': '1.8.0', + 'error.code': null, + }); + }); + }); + + describe('toEsQueryHits', () => { + it('correctly converts ESQL table to ES query hits', () => { + expect( + toEsQueryHits({ + columns: [ + { name: '@timestamp', type: 'date' }, + { name: 'ecs.version', type: 'keyword' }, + { name: 'error.code', type: 'keyword' }, + ], + values: [['2023-07-12T13:32:04.174Z', '1.8.0', null]], + }) + ).toEqual({ + hits: [ + { + _id: 'esql_query_document', + _index: '', + _source: { + '@timestamp': '2023-07-12T13:32:04.174Z', + 'ecs.version': '1.8.0', + 'error.code': null, + }, + }, + ], + total: 1, + }); + }); + }); + + describe('transformDatatableToEsqlTable', () => { + it('correctly converts data table to ESQL table', () => { + expect( + transformDatatableToEsqlTable({ + type: 'datatable', + columns: [ + { id: '@timestamp', name: '@timestamp', meta: { type: 'date' } }, + { id: 'ecs.version', name: 'ecs.version', meta: { type: 'string' } }, + { id: 'error.code', name: 'error.code', meta: { type: 'string' } }, + ], + rows: [ + { + '@timestamp': '2023-07-12T13:32:04.174Z', + 'ecs.version': '1.8.0', + 'error.code': null, + }, + ], + }) + ).toEqual({ + columns: [ + { + name: '@timestamp', + type: 'date', + }, + { + name: 'ecs.version', + type: 'string', + }, + { + name: 'error.code', + type: 'string', + }, + ], + values: [['2023-07-12T13:32:04.174Z', '1.8.0', null]], + }); + }); + }); +}); diff --git a/x-pack/plugins/stack_alerts/common/esql_query_utils.ts b/x-pack/plugins/stack_alerts/common/esql_query_utils.ts new file mode 100644 index 0000000000000..c74d3640c7fd7 --- /dev/null +++ b/x-pack/plugins/stack_alerts/common/esql_query_utils.ts @@ -0,0 +1,63 @@ +/* + * 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 { Datatable } from '@kbn/expressions-plugin/common'; + +type EsqlDocument = Record; + +interface EsqlHit { + _id: string; + _index: string; + _source: EsqlDocument; +} + +interface EsqlResultColumn { + name: string; + type: string; +} + +type EsqlResultRow = Array; + +export interface EsqlTable { + columns: EsqlResultColumn[]; + values: EsqlResultRow[]; +} + +const ESQL_DOCUMENT_ID = 'esql_query_document'; + +export const rowToDocument = (columns: EsqlResultColumn[], row: EsqlResultRow): EsqlDocument => { + return columns.reduce>((acc, column, i) => { + acc[column.name] = row[i]; + + return acc; + }, {}); +}; + +export const toEsQueryHits = (results: EsqlTable) => { + const hits: EsqlHit[] = results.values.map((row) => { + const document = rowToDocument(results.columns, row); + return { + _id: ESQL_DOCUMENT_ID, + _index: '', + _source: document, + }; + }); + + return { + hits, + total: hits.length, + }; +}; + +export const transformDatatableToEsqlTable = (results: Datatable): EsqlTable => { + const columns: EsqlResultColumn[] = results.columns.map((c) => ({ + name: c.id, + type: c.meta.type, + })); + const values: EsqlResultRow[] = results.rows.map((r) => Object.values(r)); + return { columns, values }; +}; diff --git a/x-pack/plugins/stack_alerts/common/index.ts b/x-pack/plugins/stack_alerts/common/index.ts index 4a9be641712f1..afafef61eb76b 100644 --- a/x-pack/plugins/stack_alerts/common/index.ts +++ b/x-pack/plugins/stack_alerts/common/index.ts @@ -12,3 +12,6 @@ export { getHumanReadableComparator, } from './comparator'; export { STACK_ALERTS_FEATURE_ID } from './constants'; + +export type { EsqlTable } from './esql_query_utils'; +export { rowToDocument, transformDatatableToEsqlTable, toEsQueryHits } from './esql_query_utils'; diff --git a/x-pack/plugins/stack_alerts/kibana.jsonc b/x-pack/plugins/stack_alerts/kibana.jsonc index 668d38d291a34..73b81c6dfd352 100644 --- a/x-pack/plugins/stack_alerts/kibana.jsonc +++ b/x-pack/plugins/stack_alerts/kibana.jsonc @@ -22,7 +22,8 @@ "kibanaUtils" ], "requiredBundles": [ - "esUiShared" + "esUiShared", + "textBasedLanguages" ] } } diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/constants.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/constants.ts index 55a34c7b92a79..4cce902449d18 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/constants.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/constants.ts @@ -48,6 +48,13 @@ export const ONLY_ES_QUERY_EXPRESSION_ERRORS = { timeField: new Array(), }; +export const ONLY_ESQL_QUERY_EXPRESSION_ERRORS = { + esqlQuery: new Array(), + timeField: new Array(), + thresholdComparator: new Array(), + threshold0: new Array(), +}; + const ALL_EXPRESSION_ERROR_ENTRIES = { ...COMMON_EXPRESSION_ERRORS, ...SEARCH_SOURCE_ONLY_EXPRESSION_ERRORS, diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.test.tsx new file mode 100644 index 0000000000000..b9b8ba0dd38f7 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.test.tsx @@ -0,0 +1,196 @@ +/* + * 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 { fireEvent, render, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; + +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; +import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; +import { EsqlQueryExpression } from './esql_query_expression'; +import { EsQueryRuleParams, SearchType } from '../types'; + +jest.mock('../validation', () => ({ + hasExpressionValidationErrors: jest.fn(), +})); +const { hasExpressionValidationErrors } = jest.requireMock('../validation'); + +jest.mock('@kbn/text-based-editor', () => ({ + fetchFieldsFromESQL: jest.fn(), +})); +const { fetchFieldsFromESQL } = jest.requireMock('@kbn/text-based-editor'); + +const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => ( + {children} +)); + +const dataMock = dataPluginMock.createStartContract(); +const dataViewMock = dataViewPluginMocks.createStartContract(); +const unifiedSearchMock = unifiedSearchPluginMock.createStartContract(); +const chartsStartMock = chartPluginMock.createStartContract(); + +const defaultEsqlQueryExpressionParams: EsQueryRuleParams = { + size: 100, + thresholdComparator: '>', + threshold: [0], + timeWindowSize: 15, + timeWindowUnit: 's', + index: ['test-index'], + timeField: '@timestamp', + aggType: 'count', + groupBy: 'all', + searchType: SearchType.esqlQuery, + esqlQuery: { esql: '' }, + excludeHitsFromPreviousRun: false, +}; + +describe('EsqlQueryRuleTypeExpression', () => { + beforeEach(() => { + jest.clearAllMocks(); + + hasExpressionValidationErrors.mockReturnValue(false); + }); + + it('should render EsqlQueryRuleTypeExpression with expected components', () => { + const result = render( + {}} + setRuleProperty={() => {}} + errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }} + data={dataMock} + dataViews={dataViewMock} + defaultActionGroupId="" + actionGroups={[]} + charts={chartsStartMock} + onChangeMetaData={() => {}} + />, + { + wrapper: AppWrapper, + } + ); + + expect(result.getByTestId('queryEsqlEditor')).toBeInTheDocument(); + expect(result.getByTestId('timeFieldSelect')).toBeInTheDocument(); + expect(result.getByTestId('timeWindowSizeNumber')).toBeInTheDocument(); + expect(result.getByTestId('timeWindowUnitSelect')).toBeInTheDocument(); + expect(result.queryByTestId('testQuerySuccess')).not.toBeInTheDocument(); + expect(result.queryByTestId('testQueryError')).not.toBeInTheDocument(); + }); + + test('should render Test Query button disabled if alert params are invalid', async () => { + hasExpressionValidationErrors.mockReturnValue(true); + const result = render( + {}} + setRuleProperty={() => {}} + errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }} + data={dataMock} + dataViews={dataViewMock} + defaultActionGroupId="" + actionGroups={[]} + charts={chartsStartMock} + onChangeMetaData={() => {}} + />, + { + wrapper: AppWrapper, + } + ); + + const button = result.getByTestId('testQuery'); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + }); + + test('should show success message if Test Query is successful', async () => { + fetchFieldsFromESQL.mockResolvedValue({ + type: 'datatable', + columns: [ + { id: '@timestamp', name: '@timestamp', meta: { type: 'date' } }, + { id: 'ecs.version', name: 'ecs.version', meta: { type: 'string' } }, + { id: 'error.code', name: 'error.code', meta: { type: 'string' } }, + ], + rows: [ + { + '@timestamp': '2023-07-12T13:32:04.174Z', + 'ecs.version': '1.8.0', + 'error.code': null, + }, + ], + }); + const result = render( + {}} + setRuleProperty={() => {}} + errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }} + data={dataMock} + dataViews={dataViewMock} + defaultActionGroupId="" + actionGroups={[]} + charts={chartsStartMock} + onChangeMetaData={() => {}} + />, + { + wrapper: AppWrapper, + } + ); + + fireEvent.click(result.getByTestId('testQuery')); + await waitFor(() => expect(fetchFieldsFromESQL).toBeCalled()); + + expect(result.getByTestId('testQuerySuccess')).toBeInTheDocument(); + expect(result.getByText('Query matched 1 documents in the last 15s.')).toBeInTheDocument(); + expect(result.queryByTestId('testQueryError')).not.toBeInTheDocument(); + }); + + test('should show error message if Test Query is throws error', async () => { + fetchFieldsFromESQL.mockRejectedValue('Error getting test results.!'); + const result = render( + {}} + setRuleProperty={() => {}} + errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }} + data={dataMock} + dataViews={dataViewMock} + defaultActionGroupId="" + actionGroups={[]} + charts={chartsStartMock} + onChangeMetaData={() => {}} + />, + { + wrapper: AppWrapper, + } + ); + + fireEvent.click(result.getByTestId('testQuery')); + await waitFor(() => expect(fetchFieldsFromESQL).toBeCalled()); + + expect(result.queryByTestId('testQuerySuccess')).not.toBeInTheDocument(); + expect(result.getByTestId('testQueryError')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx new file mode 100644 index 0000000000000..5a26839a7b284 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx @@ -0,0 +1,255 @@ +/* + * 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, { useState, Fragment, useEffect, useCallback } from 'react'; +import { get } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { getFields, RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { TextBasedLangEditor } from '@kbn/text-based-languages/public'; +import { fetchFieldsFromESQL } from '@kbn/text-based-editor'; +import { AggregateQuery, getIndexPatternFromESQLQuery } from '@kbn/es-query'; +import { parseDuration } from '@kbn/alerting-plugin/common'; +import { + firstFieldOption, + getTimeFieldOptions, + getTimeOptions, + parseAggregationResults, +} from '@kbn/triggers-actions-ui-plugin/public/common'; +import { EsQueryRuleParams, EsQueryRuleMetaData, SearchType } from '../types'; +import { DEFAULT_VALUES } from '../constants'; +import { useTriggerUiActionServices } from '../util'; +import { hasExpressionValidationErrors } from '../validation'; +import { TestQueryRow } from '../test_query_row'; +import { rowToDocument, toEsQueryHits, transformDatatableToEsqlTable } from '../../../../common'; + +export const EsqlQueryExpression: React.FC< + RuleTypeParamsExpressionProps, EsQueryRuleMetaData> +> = ({ ruleParams, setRuleParams, setRuleProperty, errors }) => { + const { expressions, http } = useTriggerUiActionServices(); + const { esqlQuery, timeWindowSize, timeWindowUnit, timeField } = ruleParams; + + const [currentRuleParams, setCurrentRuleParams] = useState< + EsQueryRuleParams + >({ + ...ruleParams, + timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, + // ESQL queries compare conditions within the ES query + // so only 'met' results are returned, therefore the threshold should always be 0 + threshold: [0], + thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, + size: DEFAULT_VALUES.SIZE, + esqlQuery: esqlQuery ?? { esql: '' }, + aggType: DEFAULT_VALUES.AGGREGATION_TYPE, + groupBy: DEFAULT_VALUES.GROUP_BY, + termSize: DEFAULT_VALUES.TERM_SIZE, + searchType: SearchType.esqlQuery, + }); + const [query, setQuery] = useState({ esql: '' }); + const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); + const [detectTimestamp, setDetectTimestamp] = useState(false); + + const setParam = useCallback( + (paramField: string, paramValue: unknown) => { + setCurrentRuleParams((currentParams) => ({ + ...currentParams, + [paramField]: paramValue, + })); + setRuleParams(paramField, paramValue); + }, + [setRuleParams] + ); + + const setDefaultExpressionValues = async () => { + setRuleProperty('params', currentRuleParams); + setQuery(esqlQuery ?? { esql: '' }); + if (timeField) { + setTimeFieldOptions([firstFieldOption, { text: timeField, value: timeField }]); + } + }; + useEffect(() => { + setDefaultExpressionValues(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onTestQuery = useCallback(async () => { + const window = `${timeWindowSize}${timeWindowUnit}`; + const emptyResult = { + testResults: { results: [], truncated: false }, + isGrouped: true, + timeWindow: window, + }; + + if (hasExpressionValidationErrors(currentRuleParams)) { + return emptyResult; + } + const timeWindow = parseDuration(window); + const now = Date.now(); + const table = await fetchFieldsFromESQL(esqlQuery, expressions, { + from: new Date(now - timeWindow).toISOString(), + to: new Date(now).toISOString(), + }); + if (table) { + const esqlTable = transformDatatableToEsqlTable(table); + const hits = toEsQueryHits(esqlTable); + return { + testResults: parseAggregationResults({ + isCountAgg: true, + isGroupAgg: false, + esResult: { + took: 0, + timed_out: false, + _shards: { failed: 0, successful: 0, total: 0 }, + hits, + }, + }), + isGrouped: false, + timeWindow: window, + rawResults: { + cols: esqlTable.columns.map((col) => ({ + id: col.name, + })), + rows: esqlTable.values.slice(0, 5).map((row) => rowToDocument(esqlTable.columns, row)), + }, + }; + } + return emptyResult; + }, [timeWindowSize, timeWindowUnit, currentRuleParams, esqlQuery, expressions]); + + const refreshTimeFields = async (q: AggregateQuery) => { + let hasTimestamp = false; + const indexPattern: string = getIndexPatternFromESQLQuery(get(q, 'esql')); + const currentEsFields = await getFields(http, [indexPattern]); + const timeFields = getTimeFieldOptions(currentEsFields); + setTimeFieldOptions([firstFieldOption, ...timeFields]); + + const timestampField = timeFields.find((field) => field.value === '@timestamp'); + if (timestampField) { + setParam('timeField', timestampField.value); + hasTimestamp = true; + } + setDetectTimestamp(hasTimestamp); + }; + + return ( + + +
+ +
+
+ + + { + setQuery(q); + setParam('esqlQuery', q); + refreshTimeFields(q); + }} + expandCodeEditor={() => true} + isCodeEditorExpanded={true} + onTextLangQuerySubmit={() => {}} + detectTimestamp={detectTimestamp} + hideMinimizeButton={true} + hideRunQueryText={true} + /> + + + +
+ +
+
+ + 0 && timeField !== undefined} + error={errors.timeField} + > + 0 && timeField !== undefined} + fullWidth + name="timeField" + data-test-subj="timeFieldSelect" + value={timeField || ''} + onChange={(e) => { + setParam('timeField', e.target.value); + }} + /> + + + +
+ +
+
+ + + + 0} + error={errors.timeWindowSize} + > + 0} + min={0} + value={timeWindowSize || ''} + onChange={(e) => { + const { value } = e.target; + const timeWindowSizeVal = value !== '' ? parseInt(value, 10) : undefined; + setParam('timeWindowSize', timeWindowSizeVal); + }} + /> + + + + + { + setParam('timeWindowUnit', e.target.value); + }} + options={getTimeOptions(timeWindowSize ?? 1)} + /> + + + + + +
+ ); +}; diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx index ecbfd306e1ead..09a4c3da9b5ec 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx @@ -23,7 +23,6 @@ import { EsQueryRuleTypeExpression } from './expression'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { Subject } from 'rxjs'; import { ISearchSource } from '@kbn/data-plugin/common'; -import { IUiSettingsClient } from '@kbn/core/public'; import { findTestSubject } from '@elastic/eui/lib/test'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { act } from 'react-dom/test-utils'; @@ -75,6 +74,20 @@ const defaultSearchSourceRuleParams: EsQueryRuleParams aggType: 'count', }; +const defaultEsqlRuleParams: EsQueryRuleParams = { + size: 100, + thresholdComparator: '>', + threshold: [0], + timeWindowSize: 15, + timeWindowUnit: 's', + index: ['test-index'], + timeField: '@timestamp', + searchType: SearchType.esqlQuery, + esqlQuery: { esql: 'test' }, + excludeHitsFromPreviousRun: true, + aggType: 'count', +}; + const dataViewPluginMock = dataViewPluginMocks.createStartContract(); const chartsStartMock = chartPluginMock.createStartContract(); const unifiedSearchMock = unifiedSearchPluginMock.createStartContract(); @@ -82,7 +95,7 @@ const httpMock = httpServiceMock.createStartContract(); const docLinksMock = docLinksServiceMock.createStartContract(); export const uiSettingsMock = { get: jest.fn(), -} as unknown as IUiSettingsClient; +}; const mockSearchResult = new Subject(); const searchSourceFieldsMock = { @@ -150,7 +163,10 @@ dataMock.query.savedQueries.findSavedQueries = jest.fn(() => (httpMock.post as jest.Mock).mockImplementation(() => Promise.resolve({ fields: [] })); const Wrapper: React.FC<{ - ruleParams: EsQueryRuleParams | EsQueryRuleParams; + ruleParams: + | EsQueryRuleParams + | EsQueryRuleParams + | EsQueryRuleParams; metadata?: EsQueryRuleMetaData; }> = ({ ruleParams, metadata }) => { const [currentRuleParams, setCurrentRuleParams] = useState(ruleParams); @@ -192,7 +208,10 @@ const Wrapper: React.FC<{ }; const setup = ( - ruleParams: EsQueryRuleParams | EsQueryRuleParams, + ruleParams: + | EsQueryRuleParams + | EsQueryRuleParams + | EsQueryRuleParams, metadata?: EsQueryRuleMetaData ) => { return mountWithIntl( @@ -213,11 +232,28 @@ const setup = ( }; describe('EsQueryRuleTypeExpression', () => { + beforeEach(() => { + jest.clearAllMocks(); + + uiSettingsMock.get.mockReturnValue(true); + }); + test('should render options by default', async () => { const wrapper = setup({} as EsQueryRuleParams); expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy(); expect(findTestSubject(wrapper, 'queryFormType_searchSource').exists()).toBeTruthy(); expect(findTestSubject(wrapper, 'queryFormType_esQuery').exists()).toBeTruthy(); + expect(findTestSubject(wrapper, 'queryFormType_esqlQuery').exists()).toBeTruthy(); + expect(findTestSubject(wrapper, 'queryFormTypeChooserCancel').exists()).toBeFalsy(); + }); + + test('should hide ESQL option when not enabled', async () => { + uiSettingsMock.get.mockReturnValueOnce(false); + const wrapper = setup({} as EsQueryRuleParams); + expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy(); + expect(findTestSubject(wrapper, 'queryFormType_searchSource').exists()).toBeTruthy(); + expect(findTestSubject(wrapper, 'queryFormType_esQuery').exists()).toBeTruthy(); + expect(findTestSubject(wrapper, 'queryFormType_esqlQuery').exists()).toBeFalsy(); expect(findTestSubject(wrapper, 'queryFormTypeChooserCancel').exists()).toBeFalsy(); }); @@ -257,6 +293,23 @@ describe('EsQueryRuleTypeExpression', () => { expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy(); }); + test('should switch to ESQL form type on selection and return back on cancel', async () => { + let wrapper = setup({} as EsQueryRuleParams); + await act(async () => { + findTestSubject(wrapper, 'queryFormType_esqlQuery').simulate('click'); + }); + wrapper = await wrapper.update(); + expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeFalsy(); + expect(wrapper.exists('[data-test-subj="queryEsqlEditor"]')).toBeTruthy(); + + await act(async () => { + findTestSubject(wrapper, 'queryFormTypeChooserCancel').simulate('click'); + }); + wrapper = await wrapper.update(); + expect(wrapper.exists('[data-test-subj="queryEsqlEditor"]')).toBeFalsy(); + expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy(); + }); + test('should render QueryDSL view without the form type chooser', async () => { let wrapper: ReactWrapper; await act(async () => { @@ -282,4 +335,19 @@ describe('EsQueryRuleTypeExpression', () => { expect(findTestSubject(wrapper!, 'queryFormTypeChooserCancel').exists()).toBeFalsy(); expect(findTestSubject(wrapper!, 'selectDataViewExpression').exists()).toBeTruthy(); }); + + test('should render ESQL view without the form type chooser', async () => { + let wrapper: ReactWrapper; + await act(async () => { + wrapper = setup(defaultEsqlRuleParams, { + adHocDataViewList: [], + isManagementPage: false, + }); + wrapper = await wrapper.update(); + }); + wrapper = await wrapper!.update(); + expect(findTestSubject(wrapper!, 'queryFormTypeChooserTitle').exists()).toBeFalsy(); + expect(findTestSubject(wrapper!, 'queryFormTypeChooserCancel').exists()).toBeFalsy(); + expect(wrapper.exists('[data-test-subj="queryEsqlEditor"]')).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx index 30045fee81a29..1653799a233df 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx @@ -15,8 +15,9 @@ import { EsQueryRuleParams, EsQueryRuleMetaData, SearchType } from '../types'; import { SearchSourceExpression, SearchSourceExpressionProps } from './search_source_expression'; import { EsQueryExpression } from './es_query_expression'; import { QueryFormTypeChooser } from './query_form_type_chooser'; -import { isSearchSourceRule } from '../util'; +import { isEsqlQueryRule, isSearchSourceRule } from '../util'; import { ALL_EXPRESSION_ERROR_KEYS } from '../constants'; +import { EsqlQueryExpression } from './esql_query_expression'; function areSearchSourceExpressionPropsEqual( prevProps: Readonly>, @@ -37,6 +38,7 @@ export const EsQueryRuleTypeExpression: React.FunctionComponent< > = (props) => { const { ruleParams, errors, setRuleProperty, setRuleParams } = props; const isSearchSource = isSearchSourceRule(ruleParams); + const isEsqlQuery = isEsqlQueryRule(ruleParams); // metadata provided only when open alert from Discover page const isManagementPage = props.metadata?.isManagementPage ?? true; @@ -95,10 +97,14 @@ export const EsQueryRuleTypeExpression: React.FunctionComponent< )} - {ruleParams.searchType && !isSearchSource && ( + {ruleParams.searchType && !isSearchSource && !isEsqlQuery && ( )} + {ruleParams.searchType && isEsqlQuery && ( + + )} + ); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/query_form_type_chooser.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/query_form_type_chooser.tsx index bff6792bdda3f..a2d45c78de9c9 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/query_form_type_chooser.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/query_form_type_chooser.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiButtonIcon, EuiFlexGroup, @@ -19,39 +19,7 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { SearchType } from '../types'; - -const FORM_TYPE_ITEMS: Array<{ formType: SearchType; label: string; description: string }> = [ - { - formType: SearchType.searchSource, - label: i18n.translate( - 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeLabel', - { - defaultMessage: 'KQL or Lucene', - } - ), - description: i18n.translate( - 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeDescription', - { - defaultMessage: 'Use KQL or Lucene to define a text-based query.', - } - ), - }, - { - formType: SearchType.esQuery, - label: i18n.translate( - 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeLabel', - { - defaultMessage: 'Query DSL', - } - ), - description: i18n.translate( - 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeDescription', - { - defaultMessage: 'Use the Elasticsearch Query DSL to define a query.', - } - ), - }, -]; +import { useTriggerUiActionServices } from '../util'; export interface QueryFormTypeProps { searchType: SearchType | null; @@ -62,8 +30,65 @@ export const QueryFormTypeChooser: React.FC = ({ searchType, onFormTypeSelect, }) => { + const { uiSettings } = useTriggerUiActionServices(); + const isEsqlEnabled = uiSettings?.get('discover:enableESQL'); + + const formTypeItems = useMemo(() => { + const items: Array<{ formType: SearchType; label: string; description: string }> = [ + { + formType: SearchType.searchSource, + label: i18n.translate( + 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeLabel', + { + defaultMessage: 'KQL or Lucene', + } + ), + description: i18n.translate( + 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeDescription', + { + defaultMessage: 'Use KQL or Lucene to define a text-based query.', + } + ), + }, + { + formType: SearchType.esQuery, + label: i18n.translate( + 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeLabel', + { + defaultMessage: 'Query DSL', + } + ), + description: i18n.translate( + 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeDescription', + { + defaultMessage: 'Use the Elasticsearch Query DSL to define a query.', + } + ), + }, + ]; + + if (isEsqlEnabled) { + items.push({ + formType: SearchType.esqlQuery, + label: i18n.translate( + 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.esqlFormTypeLabel', + { + defaultMessage: 'ESQL', + } + ), + description: i18n.translate( + 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.esqlFormTypeDescription', + { + defaultMessage: 'Use ESQL to define a text-based query.', + } + ), + }); + } + return items; + }, [isEsqlEnabled]); + if (searchType) { - const activeFormTypeItem = FORM_TYPE_ITEMS.find((item) => item.formType === searchType); + const activeFormTypeItem = formTypeItems.find((item) => item.formType === searchType); return ( <> @@ -107,7 +132,7 @@ export const QueryFormTypeChooser: React.FC = ({ - {FORM_TYPE_ITEMS.map((item) => ( + {formTypeItems.map((item) => ( Promise<{ @@ -27,14 +29,24 @@ export interface TestQueryRowProps { }>; copyQuery?: () => string; hasValidationErrors: boolean; + showTable?: boolean; } export const TestQueryRow: React.FC = ({ fetch, copyQuery, hasValidationErrors, + showTable, }) => { - const { onTestQuery, testQueryResult, testQueryError, testQueryLoading } = useTestQuery(fetch); + const { + onTestQuery, + testQueryResult, + testQueryError, + testQueryLoading, + testQueryRawResults, + testQueryAlerts, + } = useTestQuery(fetch); + const [copiedMessage, setCopiedMessage] = useState(null); return ( @@ -124,6 +136,12 @@ export const TestQueryRow: React.FC = ({ )} + {showTable && testQueryRawResults && ( + <> + + + + )} ); }; diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/test_query_row_table.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/test_query_row_table.test.tsx new file mode 100644 index 0000000000000..54b575be36f11 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/test_query_row_table.test.tsx @@ -0,0 +1,79 @@ +/* + * 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 { I18nProvider } from '@kbn/i18n-react'; +import { TestQueryRowTable } from './test_query_row_table'; + +const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => ( + {children} +)); + +describe('TestQueryRow', () => { + it('should render the datagrid', () => { + const result = render( + , + { + wrapper: AppWrapper, + } + ); + + expect(result.getByTestId('test-query-row-datagrid')).toBeInTheDocument(); + expect(result.getAllByTestId('dataGridRowCell')).toHaveLength(2); + expect(result.queryByText('Alerts generated')).not.toBeInTheDocument(); + expect(result.queryAllByTestId('alert-badge')).toHaveLength(0); + }); + + it('should render the datagrid and alerts if provided', () => { + const result = render( + , + { + wrapper: AppWrapper, + } + ); + + expect(result.getByTestId('test-query-row-datagrid')).toBeInTheDocument(); + expect(result.getAllByTestId('dataGridRowCell')).toHaveLength(2); + expect(result.getByText('Alerts generated')).toBeInTheDocument(); + expect(result.getAllByTestId('alert-badge')).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/test_query_row_table.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/test_query_row_table.tsx new file mode 100644 index 0000000000000..504880ee8da43 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/test_query_row_table.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 { css } from '@emotion/react'; +import { + EuiDataGrid, + EuiPanel, + EuiDataGridColumn, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiBadge, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const styles = { + grid: css` + .euiDataGridHeaderCell { + background: none; + } + .euiDataGridHeader .euiDataGridHeaderCell { + border-top: none; + } + `, +}; + +export interface TestQueryRowTableProps { + rawResults: { cols: EuiDataGridColumn[]; rows: Array> }; + alerts: string[] | null; +} + +export const TestQueryRowTable: React.FC = ({ rawResults, alerts }) => { + return ( + + c.id), + setVisibleColumns: () => {}, + }} + rowCount={rawResults.rows.length} + gridStyle={{ + border: 'horizontal', + rowHover: 'none', + }} + renderCellValue={({ rowIndex, columnId }) => rawResults.rows[rowIndex][columnId]} + pagination={{ + pageIndex: 0, + pageSize: 10, + onChangeItemsPerPage: () => {}, + onChangePage: () => {}, + }} + toolbarVisibility={false} + /> + + {alerts && ( + + + +
+ {i18n.translate('xpack.stackAlerts.esQuery.ui.testQueryAlerts', { + defaultMessage: 'Alerts generated', + })} +
+
+
+ {alerts.map((alert, index) => { + return ( + + + {alert} + + + ); + })} +
+ )} +
+ ); +}; diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/use_test_query.test.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/use_test_query.test.ts index 2067bf816eff7..44c2a1cf55717 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/use_test_query.test.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/use_test_query.test.ts @@ -20,6 +20,10 @@ describe('useTestQuery', () => { }, isGrouped: false, timeWindow: '1s', + rawResults: { + cols: [{ id: 'ungrouped', name: 'ungrouped', field: 'ungrouped', actions: false }], + rows: [{ ungrouped: 'test' }], + }, }), }); await act(async () => { @@ -29,6 +33,11 @@ describe('useTestQuery', () => { expect(result.current.testQueryError).toBe(null); expect(result.current.testQueryResult).toContain('1s'); expect(result.current.testQueryResult).toContain('1 document'); + expect(result.current.testQueryRawResults).toEqual({ + cols: [{ id: 'ungrouped', name: 'ungrouped', field: 'ungrouped', actions: false }], + rows: [{ ungrouped: 'test' }], + }); + expect(result.current.testQueryAlerts).toEqual(['query matched']); }); test('returning a valid result for grouped result', async () => { @@ -44,6 +53,10 @@ describe('useTestQuery', () => { }, isGrouped: true, timeWindow: '1s', + rawResults: { + cols: [{ id: 'grouped', name: 'grouped', field: 'grouped', actions: false }], + rows: [{ grouped: 'test' }], + }, }), }); await act(async () => { @@ -55,6 +68,11 @@ describe('useTestQuery', () => { expect(result.current.testQueryResult).toContain( 'Grouped query matched 2 groups in the last 1s.' ); + expect(result.current.testQueryRawResults).toEqual({ + cols: [{ id: 'grouped', name: 'grouped', field: 'grouped', actions: false }], + rows: [{ grouped: 'test' }], + }); + expect(result.current.testQueryAlerts).toEqual(['a', 'b']); }); test('returning an error', async () => { @@ -68,5 +86,7 @@ describe('useTestQuery', () => { expect(result.current.testQueryLoading).toBe(false); expect(result.current.testQueryError).toContain(errorMsg); expect(result.current.testQueryResult).toBe(null); + expect(result.current.testQueryRawResults).toBe(null); + expect(result.current.testQueryAlerts).toBe(null); }); }); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/use_test_query.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/use_test_query.ts index 06f230bafdccb..bac58eb2f0f43 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/use_test_query.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/test_query_row/use_test_query.ts @@ -8,17 +8,22 @@ import { useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import type { ParsedAggregationResults } from '@kbn/triggers-actions-ui-plugin/common'; +import { EuiDataGridColumn } from '@elastic/eui'; interface TestQueryResponse { result: string | null; error: string | null; isLoading: boolean; + rawResults: { cols: EuiDataGridColumn[]; rows: Array> } | null; + alerts: string[] | null; } const TEST_QUERY_INITIAL_RESPONSE: TestQueryResponse = { result: null, error: null, isLoading: false, + rawResults: null, + alerts: null, }; /** @@ -30,6 +35,7 @@ export function useTestQuery( testResults: ParsedAggregationResults; isGrouped: boolean; timeWindow: string; + rawResults?: { cols: EuiDataGridColumn[]; rows: Array> }; }> ) { const [testQueryResponse, setTestQueryResponse] = useState( @@ -46,10 +52,12 @@ export function useTestQuery( result: null, error: null, isLoading: true, + rawResults: null, + alerts: null, }); try { - const { testResults, isGrouped, timeWindow } = await fetch(); + const { testResults, isGrouped, timeWindow, rawResults } = await fetch(); if (isGrouped) { setTestQueryResponse({ @@ -62,6 +70,11 @@ export function useTestQuery( }), error: null, isLoading: false, + rawResults: rawResults ?? null, + alerts: + testResults.results.length > 0 + ? testResults.results.map((result) => result.group) + : null, }); } else { const ungroupedQueryResponse = @@ -73,6 +86,8 @@ export function useTestQuery( }), error: null, isLoading: false, + rawResults: rawResults ?? null, + alerts: ungroupedQueryResponse.count > 0 ? ['query matched'] : null, }); } } catch (err) { @@ -85,6 +100,8 @@ export function useTestQuery( values: { message: message ? `${err.message}: ${message}` : err.message }, }), isLoading: false, + rawResults: null, + alerts: null, }); } }, [fetch]); @@ -94,5 +111,7 @@ export function useTestQuery( testQueryResult: testQueryResponse.result, testQueryError: testQueryResponse.error, testQueryLoading: testQueryResponse.isLoading, + testQueryRawResults: testQueryResponse.rawResults, + testQueryAlerts: testQueryResponse.alerts, }; } diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/types.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/types.ts index d517d23af52b3..c7a298ebce22a 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/types.ts @@ -9,10 +9,12 @@ import { RuleTypeParams } from '@kbn/alerting-plugin/common'; import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { EuiComboBoxOptionOption } from '@elastic/eui'; import type { DataView } from '@kbn/data-views-plugin/public'; +import { AggregateQuery } from '@kbn/es-query'; export enum SearchType { esQuery = 'esQuery', searchSource = 'searchSource', + esqlQuery = 'esqlQuery', } export interface CommonRuleParams { @@ -38,6 +40,8 @@ export interface EsQueryRuleMetaData { export type EsQueryRuleParams = T extends SearchType.searchSource ? CommonEsQueryRuleParams & OnlySearchSourceRuleParams + : T extends SearchType.esqlQuery + ? CommonEsQueryRuleParams & OnlyEsqlQueryRuleParams : CommonEsQueryRuleParams & OnlyEsQueryRuleParams; export interface OnlyEsQueryRuleParams { @@ -53,4 +57,10 @@ export interface OnlySearchSourceRuleParams { savedQueryId?: string; } +export interface OnlyEsqlQueryRuleParams { + searchType?: 'esqlQuery'; + esqlQuery: AggregateQuery; + timeField: string; +} + export type DataViewOption = EuiComboBoxOptionOption; diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/util.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/util.ts index 5568924e845e4..7ca42220c3ebf 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/util.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/util.ts @@ -17,6 +17,12 @@ export const isSearchSourceRule = ( return ruleParams.searchType === 'searchSource'; }; +export const isEsqlQueryRule = ( + ruleParams: EsQueryRuleParams +): ruleParams is EsQueryRuleParams => { + return ruleParams.searchType === 'esqlQuery'; +}; + export const convertFieldSpecToFieldOption = (fieldSpec: FieldSpec[]): FieldOption[] => { return (fieldSpec ?? []) .filter((spec: FieldSpec) => spec.isMapped || spec.runtimeField) diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.test.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.test.ts index 0b40110f60072..f43adeab3a3a8 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.test.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.test.ts @@ -278,4 +278,63 @@ describe('expression params validation', () => { expect(validateExpression(initialParams).errors.size.length).toBe(0); expect(hasExpressionValidationErrors(initialParams)).toBe(false); }); + + test('if esqlQuery property is not set should return proper error message', () => { + const initialParams = { + size: 100, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [0], + timeField: '@timestamp', + searchType: SearchType.esqlQuery, + } as EsQueryRuleParams; + expect(validateExpression(initialParams).errors.esqlQuery.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.esqlQuery[0]).toBe(`ESQL query is required.`); + }); + + test('if esqlQuery timeField property is not defined should return proper error message', () => { + const initialParams = { + size: 100, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [0], + esqlQuery: { esql: 'test' }, + searchType: SearchType.esqlQuery, + } as EsQueryRuleParams; + expect(validateExpression(initialParams).errors.timeField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.timeField[0]).toBe('Time field is required.'); + }); + + test('if esqlQuery thresholdComparator property is not gt should return proper error message', () => { + const initialParams = { + size: 100, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [0], + esqlQuery: { esql: 'test' }, + searchType: SearchType.esqlQuery, + thresholdComparator: '<', + timeField: '@timestamp', + } as EsQueryRuleParams; + expect(validateExpression(initialParams).errors.thresholdComparator.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.thresholdComparator[0]).toBe( + 'Threshold comparator is required to be greater than.' + ); + }); + + test('if esqlQuery threshold property is not 0 should return proper error message', () => { + const initialParams = { + size: 100, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [8], + esqlQuery: { esql: 'test' }, + searchType: SearchType.esqlQuery, + timeField: '@timestamp', + } as EsQueryRuleParams; + expect(validateExpression(initialParams).errors.threshold0.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.threshold0[0]).toBe( + 'Threshold is required to be 0.' + ); + }); }); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts index 9e6eaeb7b6de0..7d7c34a76927c 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts @@ -12,11 +12,13 @@ import { builtInComparators, builtInAggregationTypes, builtInGroupByTypes, + COMPARATORS, } from '@kbn/triggers-actions-ui-plugin/public'; import { EsQueryRuleParams, SearchType } from './types'; -import { isSearchSourceRule } from './util'; +import { isEsqlQueryRule, isSearchSourceRule } from './util'; import { COMMON_EXPRESSION_ERRORS, + ONLY_ESQL_QUERY_EXPRESSION_ERRORS, ONLY_ES_QUERY_EXPRESSION_ERRORS, SEARCH_SOURCE_ONLY_EXPRESSION_ERRORS, } from './constants'; @@ -221,6 +223,46 @@ const validateEsQueryParams = (ruleParams: EsQueryRuleParams return errors; }; +const validateEsqlQueryParams = (ruleParams: EsQueryRuleParams) => { + const errors: typeof ONLY_ESQL_QUERY_EXPRESSION_ERRORS = defaultsDeep( + {}, + ONLY_ESQL_QUERY_EXPRESSION_ERRORS + ); + if (!ruleParams.esqlQuery) { + errors.esqlQuery.push( + i18n.translate('xpack.stackAlerts.esqlQuery.ui.validation.error.requiredQueryText', { + defaultMessage: 'ESQL query is required.', + }) + ); + } + if (!ruleParams.timeField) { + errors.timeField.push( + i18n.translate('xpack.stackAlerts.esqlQuery.ui.validation.error.requiredTimeFieldText', { + defaultMessage: 'Time field is required.', + }) + ); + } + if (ruleParams.thresholdComparator !== COMPARATORS.GREATER_THAN) { + errors.thresholdComparator.push( + i18n.translate( + 'xpack.stackAlerts.esqlQuery.ui.validation.error.requiredThresholdComparatorText', + { + defaultMessage: 'Threshold comparator is required to be greater than.', + } + ) + ); + } + if (ruleParams.threshold && ruleParams.threshold[0] !== 0) { + errors.threshold0.push( + i18n.translate('xpack.stackAlerts.esqlQuery.ui.validation.error.requiredThreshold0Text', { + defaultMessage: 'Threshold is required to be 0.', + }) + ); + } + + return errors; +}; + export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationResult => { const validationResult = { errors: {} }; @@ -234,8 +276,7 @@ export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationRes * It's important to report searchSource rule related errors only into errors.searchConfiguration prop. * For example errors.index is a mistake to report searchSource rule related errors. It will lead to issues. */ - const isSearchSource = isSearchSourceRule(ruleParams); - if (isSearchSource) { + if (isSearchSourceRule(ruleParams)) { validationResult.errors = { ...validationResult.errors, ...validateSearchSourceParams(ruleParams), @@ -243,6 +284,14 @@ export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationRes return validationResult; } + if (isEsqlQueryRule(ruleParams)) { + validationResult.errors = { + ...validationResult.errors, + ...validateEsqlQueryParams(ruleParams), + }; + return validationResult; + } + const esQueryErrors = validateEsQueryParams(ruleParams as EsQueryRuleParams); validationResult.errors = { ...validationResult.errors, ...esQueryErrors }; return validationResult; diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/action_context.test.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/action_context.test.ts index 9936e63aa6ed1..f9f4ab51b660f 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/action_context.test.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/action_context.test.ts @@ -167,6 +167,7 @@ describe('getContextConditionsDescription', () => { comparator: Comparator.GT, threshold: [10], aggType: 'count', + searchType: 'esQuery', }); expect(result).toBe(`Number of matching documents is greater than 10`); }); @@ -177,6 +178,7 @@ describe('getContextConditionsDescription', () => { threshold: [10], aggType: 'count', isRecovered: true, + searchType: 'esQuery', }); expect(result).toBe(`Number of matching documents is NOT greater than 10`); }); @@ -187,6 +189,7 @@ describe('getContextConditionsDescription', () => { threshold: [10, 20], aggType: 'count', isRecovered: true, + searchType: 'esQuery', }); expect(result).toBe(`Number of matching documents is NOT between 10 and 20`); }); @@ -197,6 +200,7 @@ describe('getContextConditionsDescription', () => { threshold: [10], aggType: 'count', group: 'host-1', + searchType: 'esQuery', }); expect(result).toBe(`Number of matching documents for group "host-1" is greater than 10`); }); @@ -208,6 +212,7 @@ describe('getContextConditionsDescription', () => { aggType: 'count', isRecovered: true, group: 'host-1', + searchType: 'esQuery', }); expect(result).toBe(`Number of matching documents for group "host-1" is NOT greater than 10`); }); @@ -218,6 +223,7 @@ describe('getContextConditionsDescription', () => { threshold: [10], aggType: 'min', aggField: 'numericField', + searchType: 'esQuery', }); expect(result).toBe( `Number of matching documents where min of numericField is greater than 10` @@ -231,6 +237,7 @@ describe('getContextConditionsDescription', () => { aggType: 'min', aggField: 'numericField', isRecovered: true, + searchType: 'esQuery', }); expect(result).toBe( `Number of matching documents where min of numericField is NOT greater than 10` @@ -244,6 +251,7 @@ describe('getContextConditionsDescription', () => { group: 'host-1', aggType: 'max', aggField: 'numericField', + searchType: 'esQuery', }); expect(result).toBe( `Number of matching documents for group "host-1" where max of numericField is greater than 10` @@ -258,9 +266,31 @@ describe('getContextConditionsDescription', () => { group: 'host-1', aggType: 'max', aggField: 'numericField', + searchType: 'esQuery', }); expect(result).toBe( `Number of matching documents for group "host-1" where max of numericField is NOT greater than 10` ); }); + + it('should return conditions correctly for ESQL search type', () => { + const result = getContextConditionsDescription({ + comparator: Comparator.GT, + threshold: [0], + aggType: 'count', + searchType: 'esqlQuery', + }); + expect(result).toBe(`Query matched documents`); + }); + + it('should return conditions correctly ESQL search type when isRecovered is true', () => { + const result = getContextConditionsDescription({ + comparator: Comparator.GT, + threshold: [0], + aggType: 'count', + isRecovered: true, + searchType: 'esqlQuery', + }); + expect(result).toBe(`Query did NOT match documents`); + }); }); diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/action_context.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/action_context.ts index b84601314fbdd..5de1fc15c1120 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/action_context.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/action_context.ts @@ -11,6 +11,7 @@ import { AlertInstanceContext } from '@kbn/alerting-plugin/server'; import { EsQueryRuleParams } from './rule_type_params'; import { Comparator } from '../../../common/comparator_types'; import { getHumanReadableComparator } from '../../../common'; +import { isEsqlQueryRule } from './util'; // rule type context provided to actions export interface ActionContext extends EsQueryRuleActionContext { @@ -79,6 +80,7 @@ export function addMessages({ } interface GetContextConditionsDescriptionOpts { + searchType: 'searchSource' | 'esQuery' | 'esqlQuery'; comparator: Comparator; threshold: number[]; aggType: string; @@ -88,6 +90,7 @@ interface GetContextConditionsDescriptionOpts { } export function getContextConditionsDescription({ + searchType, comparator, threshold, aggType, @@ -95,15 +98,23 @@ export function getContextConditionsDescription({ isRecovered = false, group, }: GetContextConditionsDescriptionOpts) { - return i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription', { - defaultMessage: - 'Number of matching documents{groupCondition}{aggCondition} is {negation}{thresholdComparator} {threshold}', - values: { - aggCondition: aggType === 'count' ? '' : ` where ${aggType} of ${aggField}`, - groupCondition: group ? ` for group "${group}"` : '', - thresholdComparator: getHumanReadableComparator(comparator), - threshold: threshold.join(' and '), - negation: isRecovered ? 'NOT ' : '', - }, - }); + return isEsqlQueryRule(searchType) + ? i18n.translate('xpack.stackAlerts.esQuery.esqlAlertTypeContextConditionsDescription', { + defaultMessage: 'Query{negation} documents{groupCondition}', + values: { + groupCondition: group ? ` for group "${group}"` : '', + negation: isRecovered ? ' did NOT match' : ' matched', + }, + }) + : i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription', { + defaultMessage: + 'Number of matching documents{groupCondition}{aggCondition} is {negation}{thresholdComparator} {threshold}', + values: { + aggCondition: aggType === 'count' ? '' : ` where ${aggType} of ${aggField}`, + groupCondition: group ? ` for group "${group}"` : '', + thresholdComparator: getHumanReadableComparator(comparator), + threshold: threshold.join(' and '), + negation: isRecovered ? 'NOT ' : '', + }, + }); } diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.test.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.test.ts index c33457fab43b1..43f84acf65e78 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.test.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.test.ts @@ -17,6 +17,7 @@ import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; import { EsQueryRuleParams } from './rule_type_params'; import { FetchEsQueryOpts } from './lib/fetch_es_query'; import { FetchSearchSourceQueryOpts } from './lib/fetch_search_source_query'; +import { FetchEsqlQueryOpts } from './lib/fetch_esql_query'; const logger = loggerMock.create(); const scopedClusterClientMock = elasticsearchServiceMock.createScopedClusterClient(); @@ -44,6 +45,10 @@ jest.mock('./lib/fetch_search_source_query', () => ({ fetchSearchSourceQuery: (...args: [FetchSearchSourceQueryOpts]) => mockFetchSearchSourceQuery(...args), })); +const mockFetchEsqlQuery = jest.fn(); +jest.mock('./lib/fetch_esql_query', () => ({ + fetchEsqlQuery: (...args: [FetchEsqlQueryOpts]) => mockFetchEsqlQuery(...args), +})); const mockGetRecoveredAlerts = jest.fn().mockReturnValue([]); const mockSetLimitReached = jest.fn(); @@ -86,6 +91,8 @@ describe('es_query executor', () => { excludeHitsFromPreviousRun: true, aggType: 'count', groupBy: 'all', + searchConfiguration: {}, + esqlQuery: { esql: 'test-query' }, }; describe('executor', () => { @@ -171,12 +178,12 @@ describe('es_query executor', () => { }); await executor(coreMock, { ...defaultExecutorOptions, - params: { ...defaultProps, searchConfiguration: {}, searchType: 'searchSource' }, + params: { ...defaultProps, searchType: 'searchSource' }, }); expect(mockFetchSearchSourceQuery).toHaveBeenCalledWith({ ruleId: 'test-rule-id', alertLimit: 1000, - params: { ...defaultProps, searchConfiguration: {}, searchType: 'searchSource' }, + params: { ...defaultProps, searchType: 'searchSource' }, latestTimestamp: undefined, services: { searchSourceClient: searchSourceClientMock, @@ -188,6 +195,42 @@ describe('es_query executor', () => { expect(mockFetchEsQuery).not.toHaveBeenCalled(); }); + it('should call fetchEsqlQuery if searchType is esqlQuery', async () => { + mockFetchEsqlQuery.mockResolvedValueOnce({ + parsedResults: { + results: [ + { + group: 'all documents', + count: 491, + hits: [], + }, + ], + truncated: false, + }, + dateStart: new Date().toISOString(), + dateEnd: new Date().toISOString(), + }); + await executor(coreMock, { + ...defaultExecutorOptions, + params: { ...defaultProps, searchType: 'esqlQuery' }, + }); + expect(mockFetchEsqlQuery).toHaveBeenCalledWith({ + ruleId: 'test-rule-id', + alertLimit: 1000, + params: { ...defaultProps, searchType: 'esqlQuery' }, + services: { + scopedClusterClient: scopedClusterClientMock, + logger, + share: undefined, + dataViews: undefined, + }, + spacePrefix: '', + publicBaseUrl: 'https://localhost:5601', + }); + expect(mockFetchEsQuery).not.toHaveBeenCalled(); + expect(mockFetchSearchSourceQuery).not.toHaveBeenCalled(); + }); + it('should not create alert if compare function returns false for ungrouped alert', async () => { mockFetchEsQuery.mockResolvedValueOnce({ parsedResults: { @@ -455,6 +498,78 @@ describe('es_query executor', () => { expect(mockSetLimitReached).toHaveBeenCalledWith(false); }); + it('should create alert if there are hits for ESQL alert', async () => { + mockFetchEsqlQuery.mockResolvedValueOnce({ + parsedResults: { + results: [ + { + group: 'all documents', + count: 198, + hits: [], + }, + ], + truncated: false, + }, + dateStart: new Date().toISOString(), + dateEnd: new Date().toISOString(), + link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + }); + await executor(coreMock, { + ...defaultExecutorOptions, + params: { + ...defaultProps, + searchType: 'esqlQuery', + threshold: [0], + thresholdComparator: '>=' as Comparator, + }, + }); + + expect(mockReport).toHaveBeenCalledTimes(1); + expect(mockReport).toHaveBeenNthCalledWith(1, { + actionGroup: 'query matched', + context: { + conditions: 'Query matched documents', + date: new Date(mockNow).toISOString(), + hits: [], + link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + message: `rule 'test-rule-name' is active: + +- Value: 198 +- Conditions Met: Query matched documents over 5m +- Timestamp: ${new Date(mockNow).toISOString()} +- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, + title: "rule 'test-rule-name' matched query", + value: 198, + }, + id: 'query matched', + payload: { + kibana: { + alert: { + evaluation: { + conditions: 'Query matched documents', + value: 198, + }, + reason: `rule 'test-rule-name' is active: + +- Value: 198 +- Conditions Met: Query matched documents over 5m +- Timestamp: ${new Date(mockNow).toISOString()} +- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, + title: "rule 'test-rule-name' matched query", + url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + }, + }, + }, + state: { + dateEnd: new Date(mockNow).toISOString(), + dateStart: new Date(mockNow).toISOString(), + latestTimestamp: undefined, + }, + }); + expect(mockSetLimitReached).toHaveBeenCalledTimes(1); + expect(mockSetLimitReached).toHaveBeenCalledWith(false); + }); + it('should set limit as reached if results are truncated', async () => { mockFetchEsQuery.mockResolvedValueOnce({ parsedResults: { @@ -670,6 +785,74 @@ describe('es_query executor', () => { - Value: 0 - Conditions Met: Number of matching documents for group \"host-2\" is NOT greater than or equal to 200 over 5m - Timestamp: ${new Date(mockNow).toISOString()} +- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, + title: "rule 'test-rule-name' recovered", + url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + }, + }, + }, + }); + expect(mockSetLimitReached).toHaveBeenCalledTimes(1); + expect(mockSetLimitReached).toHaveBeenCalledWith(false); + }); + + it('should correctly handle recovered alerts for ESQL alert', async () => { + mockGetRecoveredAlerts.mockReturnValueOnce([ + { + alert: { + getId: () => 'query matched', + }, + }, + ]); + mockFetchEsqlQuery.mockResolvedValueOnce({ + parsedResults: { + results: [], + truncated: false, + }, + dateStart: new Date().toISOString(), + dateEnd: new Date().toISOString(), + link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + }); + await executor(coreMock, { + ...defaultExecutorOptions, + params: { + ...defaultProps, + searchType: 'esqlQuery', + threshold: [0], + thresholdComparator: '>=' as Comparator, + }, + }); + + expect(mockReport).not.toHaveBeenCalled(); + expect(mockSetAlertData).toHaveBeenCalledTimes(1); + expect(mockSetAlertData).toHaveBeenNthCalledWith(1, { + id: 'query matched', + context: { + conditions: 'Query did NOT match documents', + date: new Date(mockNow).toISOString(), + hits: [], + link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + message: `rule 'test-rule-name' is recovered: + +- Value: 0 +- Conditions Met: Query did NOT match documents over 5m +- Timestamp: ${new Date(mockNow).toISOString()} +- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, + title: "rule 'test-rule-name' recovered", + value: 0, + }, + payload: { + kibana: { + alert: { + evaluation: { + conditions: 'Query did NOT match documents', + value: 0, + }, + reason: `rule 'test-rule-name' is recovered: + +- Value: 0 +- Conditions Met: Query did NOT match documents over 5m +- Timestamp: ${new Date(mockNow).toISOString()} - Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, title: "rule 'test-rule-name' recovered", url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.ts index ae8ae99ba26a2..ac2f619228996 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.ts @@ -19,15 +19,22 @@ import { EsQueryRuleActionContext, getContextConditionsDescription, } from './action_context'; -import { ExecutorOptions, OnlyEsQueryRuleParams, OnlySearchSourceRuleParams } from './types'; +import { + ExecutorOptions, + OnlyEsQueryRuleParams, + OnlySearchSourceRuleParams, + OnlyEsqlQueryRuleParams, +} from './types'; import { ActionGroupId, ConditionMetAlertInstanceId } from './constants'; import { fetchEsQuery } from './lib/fetch_es_query'; import { EsQueryRuleParams } from './rule_type_params'; import { fetchSearchSourceQuery } from './lib/fetch_search_source_query'; -import { isEsQueryRule } from './util'; +import { isEsqlQueryRule, isSearchSourceRule } from './util'; +import { fetchEsqlQuery } from './lib/fetch_esql_query'; export async function executor(core: CoreSetup, options: ExecutorOptions) { - const esQueryRule = isEsQueryRule(options.params.searchType); + const searchSourceRule = isSearchSourceRule(options.params.searchType); + const esqlQueryRule = isEsqlQueryRule(options.params.searchType); const { rule: { id: ruleId, name }, services, @@ -54,34 +61,47 @@ export async function executor(core: CoreSetup, options: ExecutorOptions = {}; for (const result of parsedResults.results) { const alertId = result.group; @@ -105,6 +125,7 @@ export async function executor(core: CoreSetup, options: ExecutorOptions { + const id = 'test-id'; + const { + type, + version, + attributes: { timeFieldName, fields, title }, + } = stubbedSavedObjectIndexPattern(id); + + return new DataView({ + spec: { id, type, version, timeFieldName, fields: JSON.parse(fields), title }, + fieldFormats: fieldFormatsMock, + shortDotsEnable: false, + metaFields: ['_id', '_type', '_score'], + }); +}; + +const defaultParams: OnlyEsqlQueryRuleParams = { + size: 100, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: Comparator.GT, + threshold: [0], + esqlQuery: { esql: 'from test' }, + excludeHitsFromPreviousRun: false, + searchType: 'esqlQuery', + aggType: 'count', + groupBy: 'all', + timeField: 'time', +}; + +describe('fetchEsqlQuery', () => { + describe('getEsqlQuery', () => { + const dataViewMock = createDataView(); + afterAll(() => { + jest.resetAllMocks(); + }); + + const fakeNow = new Date('2020-02-09T23:15:41.941Z'); + + beforeAll(() => { + jest.resetAllMocks(); + global.Date.now = jest.fn(() => fakeNow.getTime()); + }); + + it('should generate the correct query', async () => { + const params = defaultParams; + const { query, dateStart, dateEnd } = getEsqlQuery(dataViewMock, params, undefined); + + expect(query).toMatchInlineSnapshot(` + Object { + "filter": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "time": Object { + "format": "strict_date_optional_time", + "gt": "2020-02-09T23:10:41.941Z", + "lte": "2020-02-09T23:15:41.941Z", + }, + }, + }, + ], + }, + }, + "query": "from test", + } + `); + expect(dateStart).toMatch('2020-02-09T23:10:41.941Z'); + expect(dateEnd).toMatch('2020-02-09T23:15:41.941Z'); + }); + + it('should generate the correct query with the alertLimit', async () => { + const params = defaultParams; + const { query, dateStart, dateEnd } = getEsqlQuery(dataViewMock, params, 100); + + expect(query).toMatchInlineSnapshot(` + Object { + "filter": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "time": Object { + "format": "strict_date_optional_time", + "gt": "2020-02-09T23:10:41.941Z", + "lte": "2020-02-09T23:15:41.941Z", + }, + }, + }, + ], + }, + }, + "query": "from test | limit 100", + } + `); + expect(dateStart).toMatch('2020-02-09T23:10:41.941Z'); + expect(dateEnd).toMatch('2020-02-09T23:15:41.941Z'); + }); + }); +}); diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/lib/fetch_esql_query.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/lib/fetch_esql_query.ts new file mode 100644 index 0000000000000..07192254ca34f --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/lib/fetch_esql_query.ts @@ -0,0 +1,111 @@ +/* + * 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 { DataView, DataViewsContract, getTime } from '@kbn/data-plugin/common'; +import { parseAggregationResults } from '@kbn/triggers-actions-ui-plugin/common'; +import { SharePluginStart } from '@kbn/share-plugin/server'; +import { IScopedClusterClient, Logger } from '@kbn/core/server'; +import { OnlyEsqlQueryRuleParams } from '../types'; +import { EsqlTable, toEsQueryHits } from '../../../../common'; + +export interface FetchEsqlQueryOpts { + ruleId: string; + alertLimit: number | undefined; + params: OnlyEsqlQueryRuleParams; + spacePrefix: string; + publicBaseUrl: string; + services: { + logger: Logger; + scopedClusterClient: IScopedClusterClient; + share: SharePluginStart; + dataViews: DataViewsContract; + }; +} + +export async function fetchEsqlQuery({ + ruleId, + alertLimit, + params, + services, + spacePrefix, + publicBaseUrl, +}: FetchEsqlQueryOpts) { + const { logger, scopedClusterClient, dataViews } = services; + const esClient = scopedClusterClient.asCurrentUser; + const dataView = await dataViews.create({ + timeFieldName: params.timeField, + }); + + const { query, dateStart, dateEnd } = getEsqlQuery(dataView, params, alertLimit); + + logger.debug(`ESQL query rule (${ruleId}) query: ${JSON.stringify(query)}`); + + const response = await esClient.transport.request({ + method: 'POST', + path: '/_esql', + body: query, + }); + + const link = `${publicBaseUrl}${spacePrefix}/app/management/insightsAndAlerting/triggersActions/rule/${ruleId}`; + + return { + link, + numMatches: Number(response.values.length), + parsedResults: parseAggregationResults({ + isCountAgg: true, + isGroupAgg: false, + esResult: { + took: 0, + timed_out: false, + _shards: { failed: 0, successful: 0, total: 0 }, + hits: toEsQueryHits(response), + }, + resultLimit: alertLimit, + }), + dateStart, + dateEnd, + }; +} + +export const getEsqlQuery = ( + dataView: DataView, + params: OnlyEsqlQueryRuleParams, + alertLimit: number | undefined +) => { + const timeRange = { + from: `now-${params.timeWindowSize}${params.timeWindowUnit}`, + to: 'now', + }; + const timerangeFilter = getTime(dataView, timeRange); + const dateStart = timerangeFilter?.query.range[params.timeField].gte; + const dateEnd = timerangeFilter?.query.range[params.timeField].lte; + const rangeFilter: unknown[] = [ + { + range: { + [params.timeField]: { + lte: dateEnd, + gt: dateStart, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + const query = { + query: alertLimit ? `${params.esqlQuery.esql} | limit ${alertLimit}` : params.esqlQuery.esql, + filter: { + bool: { + filter: rangeFilter, + }, + }, + }; + return { + query, + dateStart, + dateEnd, + }; +}; diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts index 199aa47240c54..6adb6e092d56f 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts @@ -19,7 +19,11 @@ import type { ESSearchResponse, ESSearchRequest } from '@kbn/es-types'; import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import { coreMock } from '@kbn/core/server/mocks'; import { ActionGroupId, ConditionMetAlertInstanceId } from './constants'; -import { OnlyEsQueryRuleParams, OnlySearchSourceRuleParams } from './types'; +import { + OnlyEsqlQueryRuleParams, + OnlyEsQueryRuleParams, + OnlySearchSourceRuleParams, +} from './types'; import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; import { Comparator } from '../../../common/comparator_types'; import { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common/rules_settings'; @@ -108,6 +112,10 @@ describe('ruleType', () => { "description": "The indices the rule queries.", "name": "index", }, + Object { + "description": "ESQL query field used to fetch data from Elasticsearch.", + "name": "esqlQuery", + }, ], } `); @@ -596,10 +604,6 @@ describe('ruleType', () => { groupBy: 'all', }; - afterAll(() => { - jest.resetAllMocks(); - }); - it('validator succeeds with valid search source params', async () => { expect(ruleType.validate.params.validate(defaultParams)).toBeTruthy(); }); @@ -710,6 +714,144 @@ describe('ruleType', () => { ); }); }); + + describe('ESQL query', () => { + const dataViewMock = { + id: 'test-id', + title: 'test-title', + timeFieldName: 'time-field', + fields: [ + { + name: 'message', + type: 'string', + displayName: 'message', + scripted: false, + filterable: false, + aggregatable: false, + }, + { + name: 'timestamp', + type: 'date', + displayName: 'timestamp', + scripted: false, + filterable: false, + aggregatable: false, + }, + ], + toSpec: () => { + return { id: 'test-id', title: 'test-title', timeFieldName: 'timestamp', fields: [] }; + }, + }; + const defaultParams: OnlyEsqlQueryRuleParams = { + size: 100, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: Comparator.GT, + threshold: [0], + esqlQuery: { esql: 'test' }, + timeField: 'timestamp', + searchType: 'esqlQuery', + excludeHitsFromPreviousRun: true, + aggType: 'count', + groupBy: 'all', + }; + + it('validator succeeds with valid ESQL query params', async () => { + expect(ruleType.validate.params.validate(defaultParams)).toBeTruthy(); + }); + + it('validator fails with invalid ESQL query params - esQuery provided', async () => { + const paramsSchema = ruleType.validate.params; + const params: Partial> = { + size: 100, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: Comparator.LT, + threshold: [0], + esQuery: '', + searchType: 'esqlQuery', + aggType: 'count', + groupBy: 'all', + }; + + expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( + `"[esQuery]: a value wasn't expected to be present"` + ); + }); + + it('rule executor handles no documents returned by ES', async () => { + const params = defaultParams; + const ruleServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); + + (ruleServices.dataViews.create as jest.Mock).mockResolvedValueOnce({ + ...dataViewMock.toSpec(), + toSpec: () => dataViewMock.toSpec(), + }); + + const searchResult = { + columns: [ + { name: 'timestamp', type: 'date' }, + { name: 'message', type: 'keyword' }, + ], + values: [], + }; + ruleServices.scopedClusterClient.asCurrentUser.transport.request.mockResolvedValueOnce( + searchResult + ); + + await invokeExecutor({ params, ruleServices }); + expect(ruleServices.alertsClient.report).not.toHaveBeenCalled(); + }); + + it('rule executor schedule actions when condition met', async () => { + const params = defaultParams; + const ruleServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); + + (ruleServices.dataViews.create as jest.Mock).mockResolvedValueOnce({ + ...dataViewMock.toSpec(), + toSpec: () => dataViewMock.toSpec(), + }); + + const searchResult = { + columns: [ + { name: 'timestamp', type: 'date' }, + { name: 'message', type: 'keyword' }, + ], + values: [ + ['timestamp', 'message'], + ['timestamp', 'message'], + ['timestamp', 'message'], + ], + }; + ruleServices.scopedClusterClient.asCurrentUser.transport.request.mockResolvedValueOnce( + searchResult + ); + + await invokeExecutor({ params, ruleServices }); + + expect(ruleServices.alertsClient.report).toHaveBeenCalledTimes(1); + + expect(ruleServices.alertsClient.report).toHaveBeenCalledWith( + expect.objectContaining({ + actionGroup: 'query matched', + id: 'query matched', + payload: expect.objectContaining({ + kibana: { + alert: { + url: expect.any(String), + reason: expect.any(String), + title: "rule 'rule-name' matched query", + evaluation: { + conditions: 'Query matched documents', + value: 3, + }, + }, + }, + }), + }) + ); + }); + }); }); function generateResults( @@ -756,7 +898,7 @@ async function invokeExecutor({ ruleServices, state, }: { - params: OnlySearchSourceRuleParams | OnlyEsQueryRuleParams; + params: OnlySearchSourceRuleParams | OnlyEsQueryRuleParams | OnlyEsqlQueryRuleParams; ruleServices: RuleExecutorServicesMock; state?: EsQueryRuleState; }) { diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts index 01d6ee1497b3c..eabe7bf346669 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts @@ -25,7 +25,7 @@ import { STACK_ALERTS_FEATURE_ID } from '../../../common'; import { ExecutorOptions } from './types'; import { ActionGroupId, ES_QUERY_ID } from './constants'; import { executor } from './executor'; -import { isEsQueryRule } from './util'; +import { isSearchSourceRule } from './util'; export function getRuleType( core: CoreSetup @@ -134,6 +134,13 @@ export function getRuleType( } ); + const actionVariableEsqlQueryLabel = i18n.translate( + 'xpack.stackAlerts.esQuery.actionVariableContextEsqlQueryLabel', + { + defaultMessage: 'ESQL query field used to fetch data from Elasticsearch.', + } + ); + const actionVariableContextLinkLabel = i18n.translate( 'xpack.stackAlerts.esQuery.actionVariableContextLinkLabel', { @@ -179,25 +186,27 @@ export function getRuleType( { name: 'searchConfiguration', description: actionVariableSearchConfigurationLabel }, { name: 'esQuery', description: actionVariableContextQueryLabel }, { name: 'index', description: actionVariableContextIndexLabel }, + { name: 'esqlQuery', description: actionVariableEsqlQueryLabel }, ], }, useSavedObjectReferences: { extractReferences: (params) => { - if (isEsQueryRule(params.searchType)) { - return { params: params as EsQueryRuleParamsExtractedParams, references: [] }; + if (isSearchSourceRule(params.searchType)) { + const [searchConfiguration, references] = extractReferences(params.searchConfiguration); + const newParams = { ...params, searchConfiguration } as EsQueryRuleParamsExtractedParams; + return { params: newParams, references }; } - const [searchConfiguration, references] = extractReferences(params.searchConfiguration); - const newParams = { ...params, searchConfiguration } as EsQueryRuleParamsExtractedParams; - return { params: newParams, references }; + + return { params: params as EsQueryRuleParamsExtractedParams, references: [] }; }, injectReferences: (params, references) => { - if (isEsQueryRule(params.searchType)) { - return params; + if (isSearchSourceRule(params.searchType)) { + return { + ...params, + searchConfiguration: injectReferences(params.searchConfiguration, references), + }; } - return { - ...params, - searchConfiguration: injectReferences(params.searchConfiguration, references), - }; + return params; }, }, minimumLicenseRequired: 'basic', diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type_params.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type_params.ts index b9380eeafe304..1897ebad6c1ee 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type_params.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type_params.ts @@ -18,6 +18,7 @@ import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { ComparatorFnNames } from '../../../common'; import { Comparator } from '../../../common/comparator_types'; import { getComparatorSchemaType } from '../lib/comparator'; +import { isEsqlQueryRule, isSearchSourceRule } from './util'; export const ES_QUERY_MAX_HITS_PER_EXECUTION = 10000; @@ -50,9 +51,12 @@ const EsQueryRuleParamsSchemaProperties = { termField: schema.maybe(schema.string({ minLength: 1 })), // limit on number of groups returned termSize: schema.maybe(schema.number({ min: 1 })), - searchType: schema.oneOf([schema.literal('searchSource'), schema.literal('esQuery')], { - defaultValue: 'esQuery', - }), + searchType: schema.oneOf( + [schema.literal('searchSource'), schema.literal('esQuery'), schema.literal('esqlQuery')], + { + defaultValue: 'esQuery', + } + ), timeField: schema.conditional( schema.siblingRef('searchType'), schema.literal('esQuery'), @@ -79,6 +83,13 @@ const EsQueryRuleParamsSchemaProperties = { schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), schema.never() ), + // esqlQuery rule params only + esqlQuery: schema.conditional( + schema.siblingRef('searchType'), + schema.literal('esqlQuery'), + schema.object({ esql: schema.string({ minLength: 1 }) }), + schema.never() + ), }; export const EsQueryRuleParamsSchema = schema.object(EsQueryRuleParamsSchemaProperties, { @@ -142,7 +153,7 @@ function validateParams(anyParams: unknown): string | undefined { } } - if (searchType === 'searchSource') { + if (isSearchSourceRule(searchType) || isEsqlQueryRule(searchType)) { return; } diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/types.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/types.ts index b20f52f03ebe5..5f3ec1ba5e240 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/types.ts @@ -11,15 +11,26 @@ import { ActionContext } from './action_context'; import { EsQueryRuleParams, EsQueryRuleState } from './rule_type_params'; import { ActionGroupId } from './constants'; -export type OnlyEsQueryRuleParams = Omit & { +export type OnlyEsQueryRuleParams = Omit & { searchType: 'esQuery'; timeField: string; }; -export type OnlySearchSourceRuleParams = Omit & { +export type OnlySearchSourceRuleParams = Omit< + EsQueryRuleParams, + 'esQuery' | 'index' | 'esqlQuery' +> & { searchType: 'searchSource'; }; +export type OnlyEsqlQueryRuleParams = Omit< + EsQueryRuleParams, + 'esQuery' | 'index' | 'searchConfiguration' +> & { + searchType: 'esqlQuery'; + timeField: string; +}; + export type ExecutorOptions

= RuleExecutorOptions< P, EsQueryRuleState, diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/util.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/util.ts index 064a7f64b4c32..d10218fea7d4f 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/util.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/util.ts @@ -8,5 +8,13 @@ import { EsQueryRuleParams } from './rule_type_params'; export function isEsQueryRule(searchType: EsQueryRuleParams['searchType']) { - return searchType !== 'searchSource'; + return searchType === 'esQuery'; +} + +export function isSearchSourceRule(searchType: EsQueryRuleParams['searchType']) { + return searchType === 'searchSource'; +} + +export function isEsqlQueryRule(searchType: EsQueryRuleParams['searchType']) { + return searchType === 'esqlQuery'; } diff --git a/x-pack/plugins/stack_alerts/tsconfig.json b/x-pack/plugins/stack_alerts/tsconfig.json index 207e883aa8902..08a4f0ca99e9c 100644 --- a/x-pack/plugins/stack_alerts/tsconfig.json +++ b/x-pack/plugins/stack_alerts/tsconfig.json @@ -44,6 +44,9 @@ "@kbn/discover-plugin", "@kbn/rule-data-utils", "@kbn/alerts-as-data-utils", + "@kbn/text-based-languages", + "@kbn/text-based-editor", + "@kbn/expressions-plugin", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.test.ts index d2961d4725d39..900f7cd334241 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.test.ts @@ -609,6 +609,21 @@ describe('execute()', () => { took: 0, errors: true, items: [ + { + create: { + _index: 'indexme', + _id: '7buTjHQB0SuNSiS9Hayt', + status: 400, + error: { + type: 'document_parsing_exception', + reason: `[1:10] failed to parse field [bytes] of type [long] in document with id '39XQLIoB8kAjguvyIMeJ'. Preview of field's value: 'foo'`, + caused_by: { + type: 'illegal_argument_exception', + reason: `For input string: \"foo\"`, + }, + }, + }, + }, { index: { _index: 'indexme', @@ -633,7 +648,175 @@ describe('execute()', () => { config, secrets, params, - services, + services: { ...services, scopedClusterClient }, + configurationUtilities, + logger: mockedLogger, + }) + ).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "message": "error indexing documents", + "serviceMessage": "[1:10] failed to parse field [bytes] of type [long] in document with id '39XQLIoB8kAjguvyIMeJ'. Preview of field's value: 'foo';failed to parse (For input string: \\"foo\\";field name cannot be an empty string)", + "status": "error", + } + `); + }); + + test('resolves with an error when an error occurs in the indexing operation - malformed response', async () => { + const secrets = {}; + // minimal params + const config = { index: 'index-value', refresh: false, executionTimeField: null }; + const params = { + documents: [{ '': 'bob' }], + indexOverride: null, + }; + + const actionId = 'some-id'; + const scopedClusterClient = elasticsearchClientMock + .createClusterClient() + .asScoped().asCurrentUser; + // @ts-expect-error + scopedClusterClient.bulk.mockResponse({ + took: 0, + errors: true, + }); + + expect( + await connectorType.executor({ + actionId, + config, + secrets, + params, + services: { ...services, scopedClusterClient }, + configurationUtilities, + logger: mockedLogger, + }) + ).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "message": "error indexing documents", + "serviceMessage": "Indexing error but no reason returned.", + "status": "error", + } + `); + }); + + test('resolves with an error when an error occurs in the indexing operation - malformed error response', async () => { + const secrets = {}; + // minimal params + const config = { index: 'index-value', refresh: false, executionTimeField: null }; + const params = { + documents: [{ '': 'bob' }], + indexOverride: null, + }; + + const actionId = 'some-id'; + const scopedClusterClient = elasticsearchClientMock + .createClusterClient() + .asScoped().asCurrentUser; + scopedClusterClient.bulk.mockResponse({ + took: 0, + errors: true, + items: [ + { + create: { + _index: 'indexme', + _id: '7buTjHQB0SuNSiS9Hayt', + status: 400, + error: { + type: 'document_parsing_exception', + reason: `[1:10] failed to parse field [bytes] of type [long] in document with id '39XQLIoB8kAjguvyIMeJ'. Preview of field's value: 'foo'`, + caused_by: { + type: 'illegal_argument_exception', + reason: `For input string: \"foo\"`, + }, + }, + }, + }, + { + index: { + _index: 'indexme', + _id: '7buTjHQB0SuNSiS9Hayt', + status: 400, + }, + }, + ], + }); + + expect( + await connectorType.executor({ + actionId, + config, + secrets, + params, + services: { ...services, scopedClusterClient }, + configurationUtilities, + logger: mockedLogger, + }) + ).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "message": "error indexing documents", + "serviceMessage": "[1:10] failed to parse field [bytes] of type [long] in document with id '39XQLIoB8kAjguvyIMeJ'. Preview of field's value: 'foo' (For input string: \\"foo\\")", + "status": "error", + } + `); + }); + + test('resolves with an error when an error occurs in the indexing operation - error with no reason', async () => { + const secrets = {}; + // minimal params + const config = { index: 'index-value', refresh: false, executionTimeField: null }; + const params = { + documents: [{ '': 'bob' }], + indexOverride: null, + }; + + const actionId = 'some-id'; + const scopedClusterClient = elasticsearchClientMock + .createClusterClient() + .asScoped().asCurrentUser; + scopedClusterClient.bulk.mockResponse({ + took: 0, + errors: true, + items: [ + { + create: { + _index: 'indexme', + _id: '7buTjHQB0SuNSiS9Hayt', + status: 400, + error: { + type: 'document_parsing_exception', + caused_by: { + type: 'illegal_argument_exception', + }, + }, + }, + }, + { + index: { + _index: 'indexme', + _id: '7buTjHQB0SuNSiS9Hayt', + status: 400, + error: { + type: 'mapper_parsing_exception', + reason: 'failed to parse', + caused_by: { + type: 'illegal_argument_exception', + }, + }, + }, + }, + ], + }); + + expect( + await connectorType.executor({ + actionId, + config, + secrets, + params, + services: { ...services, scopedClusterClient }, configurationUtilities, logger: mockedLogger, }) @@ -641,7 +824,7 @@ describe('execute()', () => { Object { "actionId": "some-id", "message": "error indexing documents", - "serviceMessage": "Cannot read properties of undefined (reading 'items')", + "serviceMessage": "failed to parse", "status": "error", } `); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.ts index b4ee9ff01e474..5057fdb5d8312 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { find } from 'lodash'; +import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import { Logger } from '@kbn/core/server'; @@ -25,6 +25,10 @@ import { ALERT_HISTORY_PREFIX, buildAlertHistoryDocument, } from '@kbn/actions-plugin/common'; +import { + BulkOperationType, + BulkResponseItem, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; export type ESIndexConnectorType = ConnectorType< ConnectorTypeConfigType, @@ -124,15 +128,29 @@ async function executor( try { const result = await services.scopedClusterClient.bulk(bulkParams); - const err = find(result.items, 'index.error.reason'); - if (err) { - return wrapErr( - `${err.index?.error?.reason}${ - err.index?.error?.caused_by ? ` (${err.index?.error?.caused_by?.reason})` : '' - }`, - actionId, - logger - ); + if (result.errors) { + const errReason: string[] = []; + const errCausedBy: string[] = []; + // extract error reason and caused by + (result.items ?? []).forEach((item: Partial>) => { + for (const [_, responseItem] of Object.entries(item)) { + const reason = get(responseItem, 'error.reason'); + const causedBy = get(responseItem, 'error.caused_by.reason'); + if (reason) { + errReason.push(reason); + } + if (causedBy) { + errCausedBy.push(causedBy); + } + } + }); + + const errMessage = + errReason.length > 0 + ? `${errReason.join(';')}${errCausedBy.length > 0 ? ` (${errCausedBy.join(';')})` : ''}` + : `Indexing error but no reason returned.`; + + return wrapErr(errMessage, actionId, logger); } return { status: 'ok', data: result, actionId }; diff --git a/x-pack/plugins/synthetics/kibana.jsonc b/x-pack/plugins/synthetics/kibana.jsonc index 511666996f829..d03d0d384938f 100644 --- a/x-pack/plugins/synthetics/kibana.jsonc +++ b/x-pack/plugins/synthetics/kibana.jsonc @@ -41,7 +41,8 @@ "kibanaUtils", "observability", "spaces", - "indexLifecycleManagement" + "indexLifecycleManagement", + "unifiedDocViewer" ] } } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/view_document.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/view_document.tsx new file mode 100644 index 0000000000000..a37a5d8afd17e --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/view_document.tsx @@ -0,0 +1,93 @@ +/* + * 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 { EuiButtonIcon, EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; +import React, { useState } from 'react'; +import { useUnifiedDocViewerServices } from '@kbn/unified-doc-viewer-plugin/public'; +import { buildDataTableRecord } from '@kbn/discover-utils'; +import { UnifiedDocViewer } from '@kbn/unified-doc-viewer-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { useFetcher } from '@kbn/observability-shared-plugin/public'; +import { DataTableRecord } from '@kbn/discover-utils/src/types'; +import { useDateFormat } from '../../../../../hooks/use_date_format'; +import { LoadingState } from '../../monitors_page/overview/overview/monitor_detail_flyout'; +import { useSyntheticsDataView } from '../../../contexts/synthetics_data_view_context'; +import { SYNTHETICS_INDEX_PATTERN } from '../../../../../../common/constants'; +import { Ping } from '../../../../../../common/runtime_types'; + +export const ViewDocument = ({ ping }: { ping: Ping }) => { + const { data } = useUnifiedDocViewerServices(); + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + + const dataView = useSyntheticsDataView(); + const formatter = useDateFormat(); + + const { data: hit } = useFetcher>(async () => { + if (!dataView?.id || !isFlyoutVisible) return; + const response = await data.search + .search({ + params: { + index: SYNTHETICS_INDEX_PATTERN, + body: { + query: { + ids: { + values: [ping.docId], + }, + }, + fields: ['*'], + _source: false, + }, + }, + }) + .toPromise(); + const docs = response?.rawResponse?.hits?.hits ?? []; + if (docs.length > 0) { + return buildDataTableRecord(docs[0], dataView); + } + }, [data, dataView, ping.docId, isFlyoutVisible]); + + return ( + <> + { + setIsFlyoutVisible(true); + }} + /> + {isFlyoutVisible && ( + setIsFlyoutVisible(false)} ownFocus={true}> + + +

+ {INDEXED_AT} {formatter(ping.timestamp)} +

+ + + + {dataView?.id && hit ? ( + + ) : ( + + )} + + + )} + + ); +}; + +const INDEXED_AT = i18n.translate('xpack.synthetics.monitorDetails.summary.indexedAt', { + defaultMessage: 'Indexed at', +}); + +export const INSPECT_DOCUMENT = i18n.translate( + 'xpack.synthetics.monitorDetails.action.inspectDocument', + { + defaultMessage: 'Inspect document', + } +); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_monitor_errors.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_monitor_errors.tsx index 4e4f9a6c61b17..969e98a21c36c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_monitor_errors.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_monitor_errors.tsx @@ -8,7 +8,7 @@ import { useTimeZone } from '@kbn/observability-shared-plugin/public'; import { useParams } from 'react-router-dom'; import { useMemo } from 'react'; import { useSelectedLocation } from './use_selected_location'; -import { PingState } from '../../../../../../common/runtime_types'; +import { Ping, PingState } from '../../../../../../common/runtime_types'; import { EXCLUDE_RUN_ONCE_FILTER, SUMMARY_FILTER, @@ -113,10 +113,11 @@ export function useMonitorErrors(monitorIdArg?: string) { return prev; }, defaultValues) ?? defaultValues; + const hits = data?.aggregations?.latest.hits.hits ?? []; + const hasActiveError: boolean = - data?.aggregations?.latest.hits.hits.length === 1 && - (data?.aggregations?.latest.hits.hits[0]._source as { monitor: { status: string } }).monitor - .status === 'down' && + hits.length === 1 && + (hits[0]?._source as Ping).monitor?.status === 'down' && !!errorStates?.length; return { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/test_runs_table.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/test_runs_table.tsx index 97b5f2b521716..0bbbd3a5f247b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/test_runs_table.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/test_runs_table.tsx @@ -21,6 +21,7 @@ import { } from '@elastic/eui'; import { Criteria } from '@elastic/eui/src/components/basic_table/basic_table'; import { EuiTableSortingType } from '@elastic/eui/src/components/basic_table/table_types'; +import { INSPECT_DOCUMENT, ViewDocument } from '../../common/components/view_document'; import { ExpandRowColumn, toggleDetails, @@ -206,6 +207,20 @@ export const TestRunsTable = ({ show: false, }, }, + { + align: 'right' as const, + actions: [ + { + 'data-test-subj': 'syntheticsViewPingDocument', + isPrimary: true, + name: INSPECT_DOCUMENT, + description: INSPECT_DOCUMENT, + icon: 'inspect' as const, + type: 'button' as const, + render: (ping: Ping) => , + }, + ], + }, ...(!isBrowserMonitor ? [ { @@ -229,10 +244,17 @@ export const TestRunsTable = ({ 'data-test-subj': `row-${item.monitor.check_group}`, onClick: (evt: MouseEvent) => { const targetElem = evt.target as HTMLElement; + const isTableRow = + targetElem.parentElement?.classList.contains('euiTableCellContent') || + targetElem.parentElement?.classList.contains('euiTableCellContent__text') || + targetElem?.classList.contains('euiTableCellContent') || + targetElem?.classList.contains('euiBadge__text'); // we dont want to capture image click event if ( + isTableRow && targetElem.tagName !== 'IMG' && targetElem.tagName !== 'path' && + targetElem.tagName !== 'BUTTON' && !targetElem.parentElement?.classList.contains('euiLink') ) { if (item.monitor.type !== MONITOR_TYPES.BROWSER) { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/test_now_mode/simple/ping_list/columns/expand_row.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_now_mode/simple/ping_list/columns/expand_row.tsx index b756cfb082cb5..9ef5ceacc6183 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/test_now_mode/simple/ping_list/columns/expand_row.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_now_mode/simple/ping_list/columns/expand_row.tsx @@ -16,6 +16,11 @@ export const toggleDetails = ( expandedRows: Record, setExpandedRows: (update: Record) => any ) => { + // prevent expanding on row click if not expandable + if (!rowShouldExpand(ping)) { + return; + } + // If already expanded, collapse if (expandedRows[ping.docId]) { delete expandedRows[ping.docId]; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/contexts/synthetics_data_view_context.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/contexts/synthetics_data_view_context.tsx new file mode 100644 index 0000000000000..4e38a3de25388 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/contexts/synthetics_data_view_context.tsx @@ -0,0 +1,25 @@ +/* + * 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, { createContext, useContext } from 'react'; +import { useFetcher } from '@kbn/observability-shared-plugin/public'; +import { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/public'; +import { SYNTHETICS_INDEX_PATTERN } from '../../../../common/constants'; + +export const SyntheticsDataViewContext = createContext({} as DataView); + +export const SyntheticsDataViewContextProvider: React.FC<{ + dataViews: DataViewsPublicPluginStart; +}> = ({ children, dataViews }) => { + const { data } = useFetcher>(async () => { + return dataViews.create({ title: SYNTHETICS_INDEX_PATTERN }); + }, []); + + return ; +}; + +export const useSyntheticsDataView = () => useContext(SyntheticsDataViewContext); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx index f9b37b64df403..bac16812f1bf2 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx @@ -18,6 +18,7 @@ import { import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { InspectorContextProvider } from '@kbn/observability-shared-plugin/public'; import { ObservabilityAIAssistantProvider } from '@kbn/observability-ai-assistant-plugin/public'; +import { SyntheticsDataViewContextProvider } from './contexts/synthetics_data_view_context'; import { SyntheticsAppProps } from './contexts'; import { @@ -99,32 +100,34 @@ const Application = (props: SyntheticsAppProps) => { fleet: startPlugins.fleet, }} > - - - - - - - -
- - - - - - - -
-
-
-
-
-
-
-
+ + + + + + + + +
+ + + + + + + +
+
+
+
+
+
+
+
+
diff --git a/x-pack/plugins/synthetics/tsconfig.json b/x-pack/plugins/synthetics/tsconfig.json index 12bc371fef915..ff288ec3cee97 100644 --- a/x-pack/plugins/synthetics/tsconfig.json +++ b/x-pack/plugins/synthetics/tsconfig.json @@ -77,6 +77,8 @@ "@kbn/core-saved-objects-server-mocks", "@kbn/shared-ux-page-kibana-template", "@kbn/observability-ai-assistant-plugin", + "@kbn/unified-doc-viewer-plugin", + "@kbn/discover-utils", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/triggers_actions_ui/kibana.jsonc b/x-pack/plugins/triggers_actions_ui/kibana.jsonc index 653c105772711..eb660c0bbe383 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.jsonc +++ b/x-pack/plugins/triggers_actions_ui/kibana.jsonc @@ -24,6 +24,7 @@ "actions", "dashboard", "licensing", + "expressions" ], "optionalPlugins": [ "cloud", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx index 263c13ccd4981..a8d58e2e627f3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx @@ -30,6 +30,7 @@ import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import { ruleDetailsRoute } from '@kbn/rule-data-utils'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { DashboardStart } from '@kbn/dashboard-plugin/public'; +import { ExpressionsStart } from '@kbn/expressions-plugin/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import { ActionTypeRegistryContract, @@ -70,6 +71,7 @@ export interface TriggersAndActionsUiServices extends CoreStart { theme$: Observable; unifiedSearch: UnifiedSearchPublicPluginStart; licensing: LicensingPluginStart; + expressions: ExpressionsStart; } export const renderApp = (deps: TriggersAndActionsUiServices) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/common/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/index.ts index f9b3cc7c8654c..52b39919227ea 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/index.ts @@ -24,7 +24,7 @@ export { export { connectorDeprecatedMessage, deprecatedMessage } from './connectors_selection'; export type { IOption } from './index_controls'; export { getFields, getIndexOptions, firstFieldOption } from './index_controls'; -export { getTimeFieldOptions, useKibana } from './lib'; +export { getTimeFieldOptions, getTimeOptions, useKibana } from './lib'; export type { Comparator, AggregationType, diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts index 28e3c29d377b3..5cc996ea997ec 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts @@ -21,6 +21,7 @@ import { AlertsTableConfigurationRegistryContract, } from '../../../types'; import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; +import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks'; export const createStartServicesMock = (): TriggersAndActionsUiServices => { const core = coreMock.createStart(); @@ -70,6 +71,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => { } as unknown as HTMLElement, theme$: themeServiceMock.createTheme$(), licensing: licensingPluginMock, + expressions: expressionsPluginMock.createStartContract(), } as TriggersAndActionsUiServices; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 3bc8ef4b9f75b..a13df899ec3cf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -116,6 +116,7 @@ export { getIndexOptions, firstFieldOption, getTimeFieldOptions, + getTimeOptions, GroupByExpression, COMPARATORS, connectorDeprecatedMessage, diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index 6f66f38e8541a..689d841b9c1f5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -26,6 +26,7 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/ import { triggersActionsRoute } from '@kbn/rule-data-utils'; import { DashboardStart } from '@kbn/dashboard-plugin/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { AlertsSearchBarProps } from './application/sections/alerts_search_bar'; import { TypeRegistry } from './application/type_registry'; @@ -161,6 +162,7 @@ interface PluginsStart { spaces?: SpacesPluginStart; navigateToApp: CoreStart['application']['navigateToApp']; features: FeaturesPluginStart; + expressions: ExpressionsStart; unifiedSearch: UnifiedSearchPublicPluginStart; licensing: LicensingPluginStart; } @@ -287,6 +289,7 @@ export class Plugin alertsTableConfigurationRegistry, kibanaFeatures, licensing: pluginsStart.licensing, + expressions: pluginsStart.expressions, }); }, }); diff --git a/x-pack/plugins/triggers_actions_ui/tsconfig.json b/x-pack/plugins/triggers_actions_ui/tsconfig.json index 42e53b4f313b2..3bfe7239fac2c 100644 --- a/x-pack/plugins/triggers_actions_ui/tsconfig.json +++ b/x-pack/plugins/triggers_actions_ui/tsconfig.json @@ -54,6 +54,7 @@ "@kbn/core-ui-settings-common", "@kbn/dashboard-plugin", "@kbn/licensing-plugin", + "@kbn/expressions-plugin", ], "exclude": ["target/**/*"] } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/esql_only.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/esql_only.ts new file mode 100644 index 0000000000000..eee79e38a2dac --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/esql_only.ts @@ -0,0 +1,340 @@ +/* + * 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 expect from '@kbn/expect'; +import { Spaces } from '../../../../../scenarios'; +import { FtrProviderContext } from '../../../../../../common/ftr_provider_context'; +import { getUrlPrefix, ObjectRemover } from '../../../../../../common/lib'; +import { + createConnector, + ES_GROUPS_TO_WRITE, + ES_TEST_DATA_STREAM_NAME, + ES_TEST_INDEX_REFERENCE, + ES_TEST_INDEX_SOURCE, + ES_TEST_OUTPUT_INDEX_NAME, + getRuleServices, + RULE_INTERVALS_TO_WRITE, + RULE_INTERVAL_MILLIS, + RULE_INTERVAL_SECONDS, + RULE_TYPE_ID, +} from './common'; +import { createDataStream, deleteDataStream } from '../../../create_test_data'; + +// eslint-disable-next-line import/no-default-export +export default function ruleTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const { + es, + esTestIndexTool, + esTestIndexToolOutput, + esTestIndexToolDataStream, + createEsDocumentsInGroups, + removeAllAADDocs, + getAllAADDocs, + } = getRuleServices(getService); + + describe('rule', async () => { + let endDate: string; + let connectorId: string; + const objectRemover = new ObjectRemover(supertest); + + beforeEach(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + + await esTestIndexToolOutput.destroy(); + await esTestIndexToolOutput.setup(); + + connectorId = await createConnector(supertest, objectRemover, ES_TEST_OUTPUT_INDEX_NAME); + + // write documents in the future, figure out the end date + const endDateMillis = Date.now() + (RULE_INTERVALS_TO_WRITE - 1) * RULE_INTERVAL_MILLIS; + endDate = new Date(endDateMillis).toISOString(); + + await createDataStream(es, ES_TEST_DATA_STREAM_NAME); + }); + + afterEach(async () => { + await objectRemover.removeAll(); + await esTestIndexTool.destroy(); + await esTestIndexToolOutput.destroy(); + await deleteDataStream(es, ES_TEST_DATA_STREAM_NAME); + await removeAllAADDocs(); + }); + + it('runs correctly: threshold on ungrouped hit count < >', async () => { + // write documents from now to the future end date in groups + await createEsDocumentsInGroups(ES_GROUPS_TO_WRITE, endDate); + await createRule({ + name: 'never fire', + esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 0', + size: 100, + }); + await createRule({ + name: 'always fire', + esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c > -1', + size: 100, + }); + + const docs = await waitForDocs(2); + const messagePattern = + /rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/; + + for (let i = 0; i < docs.length; i++) { + const doc = docs[i]; + const { hits } = doc._source; + const { name, title, message } = doc._source.params; + + expect(name).to.be('always fire'); + expect(title).to.be(`rule 'always fire' matched query`); + expect(message).to.match(messagePattern); + expect(hits).not.to.be.empty(); + } + + const aadDocs = await getAllAADDocs(1); + + const alertDoc = aadDocs.body.hits.hits[0]._source.kibana.alert; + expect(alertDoc.reason).to.match(messagePattern); + expect(alertDoc.title).to.be("rule 'always fire' matched query"); + expect(alertDoc.evaluation.conditions).to.be('Query matched documents'); + expect(alertDoc.evaluation.value).greaterThan(0); + expect(alertDoc.url).to.contain('/s/space1/app/'); + }); + + it('runs correctly: use epoch millis - threshold on hit count < >', async () => { + // write documents from now to the future end date in groups + const endDateMillis = Date.now() + (RULE_INTERVALS_TO_WRITE - 1) * RULE_INTERVAL_MILLIS; + endDate = new Date(endDateMillis).toISOString(); + await createEsDocumentsInGroups(ES_GROUPS_TO_WRITE, endDate); + await createRule({ + name: 'never fire', + esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 0', + size: 100, + timeField: 'date_epoch_millis', + }); + await createRule({ + name: 'always fire', + esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c > -1', + size: 100, + timeField: 'date_epoch_millis', + }); + + const docs = await waitForDocs(2); + for (let i = 0; i < docs.length; i++) { + const doc = docs[i]; + const { hits } = doc._source; + const { name, title, message } = doc._source.params; + + expect(name).to.be('always fire'); + expect(title).to.be(`rule 'always fire' matched query`); + const messagePattern = + /rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/; + expect(message).to.match(messagePattern); + expect(hits).not.to.be.empty(); + } + }); + + it('runs correctly: no matches', async () => { + await createRule({ + name: 'always fire', + esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 1', + size: 100, + }); + + const docs = await waitForDocs(1); + for (let i = 0; i < docs.length; i++) { + const doc = docs[i]; + const { hits } = doc._source; + const { name, title, message } = doc._source.params; + + expect(name).to.be('always fire'); + expect(title).to.be(`rule 'always fire' matched query`); + const messagePattern = + /rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/; + expect(message).to.match(messagePattern); + expect(hits).not.to.be.empty(); + } + }); + + it('runs correctly and populates recovery context', async () => { + // This rule should be active initially when the number of documents is below the threshold + // and then recover when we add more documents. + await createRule({ + name: 'fire then recovers', + esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 1', + size: 100, + notifyWhen: 'onActionGroupChange', + timeWindowSize: RULE_INTERVAL_SECONDS, + }); + + let docs = await waitForDocs(1); + const activeDoc = docs[0]; + const { + name: activeName, + title: activeTitle, + value: activeValue, + message: activeMessage, + } = activeDoc._source.params; + + expect(activeName).to.be('fire then recovers'); + expect(activeTitle).to.be(`rule 'fire then recovers' matched query`); + expect(activeValue).to.be('1'); + expect(activeMessage).to.match( + /rule 'fire then recovers' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 4s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/ + ); + await createEsDocumentsInGroups(1, endDate); + docs = await waitForDocs(2); + const recoveredDoc = docs[1]; + const { + name: recoveredName, + title: recoveredTitle, + message: recoveredMessage, + } = recoveredDoc._source.params; + + expect(recoveredName).to.be('fire then recovers'); + expect(recoveredTitle).to.be(`rule 'fire then recovers' recovered`); + expect(recoveredMessage).to.match( + /rule 'fire then recovers' is recovered:\n\n- Value: \d+\n- Conditions Met: Query did NOT match documents over 4s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/ + ); + }); + + it('runs correctly over a data stream: threshold on hit count < >', async () => { + // write documents from now to the future end date in groups + await createEsDocumentsInGroups( + ES_GROUPS_TO_WRITE, + endDate, + esTestIndexToolDataStream, + ES_TEST_DATA_STREAM_NAME + ); + await createRule({ + name: 'never fire', + esqlQuery: 'from test-data-stream | stats c = count(@timestamp) | where c < 0', + size: 100, + }); + await createRule({ + name: 'always fire', + esqlQuery: 'from test-data-stream | stats c = count(@timestamp) | where c > -1', + size: 100, + }); + + const docs = await waitForDocs(2); + for (let i = 0; i < docs.length; i++) { + const doc = docs[i]; + const { hits } = doc._source; + const { name, title, message } = doc._source.params; + + expect(name).to.be('always fire'); + expect(title).to.be(`rule 'always fire' matched query`); + const messagePattern = + /rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/; + expect(message).to.match(messagePattern); + expect(hits).not.to.be.empty(); + } + }); + + async function waitForDocs(count: number): Promise { + return await esTestIndexToolOutput.waitForDocs( + ES_TEST_INDEX_SOURCE, + ES_TEST_INDEX_REFERENCE, + count + ); + } + + interface CreateRuleParams { + name: string; + size: number; + esqlQuery: string; + timeWindowSize?: number; + timeField?: string; + notifyWhen?: string; + aggType?: string; + aggField?: string; + groupBy?: string; + termField?: string; + termSize?: number; + } + + async function createRule(params: CreateRuleParams): Promise { + const action = { + id: connectorId, + group: 'query matched', + params: { + documents: [ + { + source: ES_TEST_INDEX_SOURCE, + reference: ES_TEST_INDEX_REFERENCE, + params: { + name: '{{{rule.name}}}', + value: '{{{context.value}}}', + title: '{{{context.title}}}', + message: '{{{context.message}}}', + }, + hits: '{{context.hits}}', + date: '{{{context.date}}}', + previousTimestamp: '{{{state.latestTimestamp}}}', + }, + ], + }, + }; + + const recoveryAction = { + id: connectorId, + group: 'recovered', + params: { + documents: [ + { + source: ES_TEST_INDEX_SOURCE, + reference: ES_TEST_INDEX_REFERENCE, + params: { + name: '{{{rule.name}}}', + value: '{{{context.value}}}', + title: '{{{context.title}}}', + message: '{{{context.message}}}', + }, + hits: '{{context.hits}}', + date: '{{{context.date}}}', + }, + ], + }, + }; + + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send({ + name: params.name, + consumer: 'alerts', + enabled: true, + rule_type_id: RULE_TYPE_ID, + schedule: { interval: `${RULE_INTERVAL_SECONDS}s` }, + actions: [action, recoveryAction], + notify_when: params.notifyWhen || 'onActiveAlert', + params: { + size: params.size, + timeWindowSize: params.timeWindowSize || RULE_INTERVAL_SECONDS * 5, + timeWindowUnit: 's', + thresholdComparator: '>', + threshold: [0], + searchType: 'esqlQuery', + aggType: params.aggType, + groupBy: params.groupBy, + aggField: params.aggField, + termField: params.termField, + termSize: params.termSize, + timeField: params.timeField || 'date', + esqlQuery: { esql: params.esqlQuery }, + }, + }) + .expect(200); + + const ruleId = createdRule.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); + + return ruleId; + } + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/index.ts index a584753db7c25..758737d9749b6 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/index.ts @@ -12,5 +12,6 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { describe('es_query', () => { loadTestFile(require.resolve('./rule')); loadTestFile(require.resolve('./query_dsl_only')); + loadTestFile(require.resolve('./esql_only')); }); } diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/toggle_column.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/toggle_column.cy.ts index 5cdb4462839c2..c2ca620a40554 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/toggle_column.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/toggle_column.cy.ts @@ -19,30 +19,34 @@ import { import { HOSTS_URL } from '../../../urls/navigation'; -describe('toggle column in timeline', { tags: ['@ess', '@serverless'] }, () => { - before(() => { - cleanKibana(); - cy.intercept('POST', '/api/timeline/_export?file_name=timelines_export.ndjson').as('export'); - }); - - beforeEach(() => { - login(); - visit(HOSTS_URL); - openTimelineUsingToggle(); - populateTimeline(); - }); - - it('removes the @timestamp field from the timeline when the user un-checks the toggle', () => { - expandFirstTimelineEventDetails(); - clickTimestampToggleField(); - - cy.get(TIMESTAMP_HEADER_FIELD).should('not.exist'); - }); - - it('adds the _id field to the timeline when the user checks the field', () => { - expandFirstTimelineEventDetails(); - clickIdToggleField(); - - cy.get(ID_HEADER_FIELD).should('exist'); - }); -}); +describe( + 'toggle column in timeline', + { tags: ['@ess', '@serverless', '@brokenInServerless'] }, + () => { + before(() => { + cleanKibana(); + cy.intercept('POST', '/api/timeline/_export?file_name=timelines_export.ndjson').as('export'); + }); + + beforeEach(() => { + login(); + visit(HOSTS_URL); + openTimelineUsingToggle(); + populateTimeline(); + }); + + it('removes the @timestamp field from the timeline when the user un-checks the toggle', () => { + expandFirstTimelineEventDetails(); + clickTimestampToggleField(); + + cy.get(TIMESTAMP_HEADER_FIELD).should('not.exist'); + }); + + it('adds the _id field to the timeline when the user checks the field', () => { + expandFirstTimelineEventDetails(); + clickIdToggleField(); + + cy.get(ID_HEADER_FIELD).should('exist'); + }); + } +); diff --git a/x-pack/test_serverless/api_integration/test_suites/common/alerting/rules.ts b/x-pack/test_serverless/api_integration/test_suites/common/alerting/rules.ts index 1ad798758b45a..bfce78384f601 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/alerting/rules.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/alerting/rules.ts @@ -35,8 +35,7 @@ export default function ({ getService }: FtrProviderContext) { const esClient = getService('es'); const esDeleteAllIndices = getService('esDeleteAllIndices'); - // Issue: https://github.com/elastic/kibana/issues/165145 - describe.skip('Alerting rules', () => { + describe('Alerting rules', () => { const RULE_TYPE_ID = '.es-query'; const ALERT_ACTION_INDEX = 'alert-action-es-query'; let actionId: string; diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/common/apm_api_supertest.ts b/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/common/apm_api_supertest.ts index c31ee37a93b94..ac324b3fda087 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/common/apm_api_supertest.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/common/apm_api_supertest.ts @@ -4,15 +4,14 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { ApmUsername } from '@kbn/apm-plugin/server/test_helpers/create_apm_users/authentication'; -import { format, UrlObject } from 'url'; +import { format } from 'url'; import supertest from 'supertest'; import request from 'superagent'; import type { APIReturnType, APIClientRequestParamsOf, } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api'; -import { kbnTestConfig } from '@kbn/test'; +import { Config, kbnTestConfig, kibanaTestSuperuserServerless } from '@kbn/test'; import type { APIEndpoint } from '@kbn/apm-plugin/server'; import { formatRequest } from '@kbn/server-route-repository'; import { InheritedFtrProviderContext } from '../../../../services'; @@ -93,19 +92,19 @@ Body: ${JSON.stringify(res.body)}` } } -async function getApmApiClient({ - kibanaServer, - username, -}: { - kibanaServer: UrlObject; - username: ApmUsername | 'elastic'; -}) { +async function getApmApiClient({ svlSharedConfig }: { svlSharedConfig: Config }) { + const kibanaServer = svlSharedConfig.get('servers.kibana'); + const cAuthorities = svlSharedConfig.get('servers.kibana.certificateAuthorities'); + + const username = kbnTestConfig.getUrlParts(kibanaTestSuperuserServerless).username; + const password = kbnTestConfig.getUrlParts(kibanaTestSuperuserServerless).password; + const url = format({ ...kibanaServer, - auth: `${username}:${kbnTestConfig.getUrlParts().password}`, + auth: `${username}:${password}`, }); - return createApmApiClient(supertest(url)); + return createApmApiClient(supertest.agent(url, { ca: cAuthorities })); } export interface SupertestReturnType { @@ -120,12 +119,10 @@ export async function getApmApiClientService({ getService, }: InheritedFtrProviderContext): Promise { const svlSharedConfig = getService('config'); - const kibanaServer = svlSharedConfig.get('servers.kibana'); return { slsUser: await getApmApiClient({ - kibanaServer, - username: 'elastic', + svlSharedConfig, }), }; } diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/feature_flags.ts b/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/feature_flags.ts index 93c621c72af74..279a1e0970b57 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/feature_flags.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/feature_flags.ts @@ -7,6 +7,7 @@ import expect from 'expect'; import { APMFtrContextProvider } from './common/services'; +import { ApmApiClient } from './common/apm_api_supertest'; const fleetMigrationResponse = { statusCode: 404, @@ -48,7 +49,7 @@ const SAMPLE_SOURCEMAP = { mappings: 'A,AAAB;;ABCDE;', }; -async function uploadSourcemap(apmApiClient: any) { +async function uploadSourcemap(apmApiClient: ApmApiClient) { const response = await apmApiClient.slsUser({ endpoint: 'POST /api/apm/sourcemaps 2023-10-31', type: 'form-data', @@ -67,8 +68,7 @@ async function uploadSourcemap(apmApiClient: any) { export default function ({ getService }: APMFtrContextProvider) { const apmApiClient = getService('apmApiClient'); - // Issue: https://github.com/elastic/kibana/issues/165138 - describe.skip('apm feature flags', () => { + describe('apm feature flags', () => { describe('fleet migrations', () => { it('rejects requests to save apm server schema', async () => { try { diff --git a/x-pack/test_serverless/functional/test_suites/common/examples/search/warnings.ts b/x-pack/test_serverless/functional/test_suites/common/examples/search/warnings.ts index 026fa9f2efd25..b1e5be5f9f835 100644 --- a/x-pack/test_serverless/functional/test_suites/common/examples/search/warnings.ts +++ b/x-pack/test_serverless/functional/test_suites/common/examples/search/warnings.ts @@ -24,7 +24,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const esArchiver = getService('esArchiver'); - describe('handling warnings with search source fetch', function () { + // Failing: See https://github.com/elastic/kibana/issues/165623 + describe.skip('handling warnings with search source fetch', function () { const dataViewTitle = 'sample-01,sample-01-rollup'; const fromTime = 'Jun 17, 2022 @ 00:00:00.000'; const toTime = 'Jun 23, 2022 @ 00:00:00.000'; diff --git a/x-pack/test_serverless/functional/test_suites/common/security/api_keys.ts b/x-pack/test_serverless/functional/test_suites/common/security/api_keys.ts index 99f01f2d19a19..32c12c9b713b4 100644 --- a/x-pack/test_serverless/functional/test_suites/common/security/api_keys.ts +++ b/x-pack/test_serverless/functional/test_suites/common/security/api_keys.ts @@ -29,7 +29,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const es = getService('es'); const log = getService('log'); - describe('API keys', () => { + // FLAKY: https://github.com/elastic/kibana/issues/165553 + describe.skip('API keys', () => { after(async () => { await clearAllApiKeys(es, log); }); diff --git a/yarn.lock b/yarn.lock index 47f2d2206ecd9..006b36f36eb02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13639,7 +13639,7 @@ css-what@^6.0.1, css-what@^6.1.0: css.escape@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" - integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s= + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== css@2.X, css@^2.2.1, css@^2.2.4: version "2.2.4"