Merge branch '8.19' into update-bundled-packages-20250625161128

This commit is contained in:
Julia Bardi 2025-06-26 10:19:28 +02:00 committed by GitHub
commit 2cf151312d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 7510 additions and 1064 deletions

View file

@ -30,13 +30,13 @@ disabled:
- x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/config.ts
- x-pack/platform/test/alerting_api_integration/spaces_only_legacy/config.ts
- x-pack/test/cloud_integration/config.ts
- x-pack/test/load/config.ts
- x-pack/test/plugin_api_perf/config.js
- x-pack/platform/test/load/config.ts
- x-pack/platform/test/plugin_api_perf/config.ts
- x-pack/test/screenshot_creation/config.ts
- x-pack/test/fleet_packages/config.ts
- x-pack/platform/test/fleet_packages/config.ts
# Scalability testing config that we run in its own pipeline
- x-pack/test/scalability/config.ts
- x-pack/platform/test/scalability/config.ts
# Cypress configs, for now these are still run manually
- x-pack/test/fleet_cypress/cli_config.ts
@ -171,7 +171,7 @@ enabled:
- x-pack/test/fleet_api_integration/config.package_policy.ts
- x-pack/test/fleet_api_integration/config.space_awareness.ts
- x-pack/test/fleet_functional/config.ts
- x-pack/test/ftr_apis/security_and_spaces/config.ts
- x-pack/platform/test/ftr_apis/security_and_spaces/config.ts
- x-pack/test/functional_basic/apps/ml/permissions/config.ts
- x-pack/test/functional_basic/apps/ml/data_visualizer/group1/config.ts
- x-pack/test/functional_basic/apps/ml/data_visualizer/group2/config.ts
@ -327,7 +327,7 @@ enabled:
- x-pack/test/spaces_api_integration/security_and_spaces/config_trial.ts
- x-pack/test/spaces_api_integration/security_and_spaces/copy_to_space_config_trial.ts
- x-pack/test/spaces_api_integration/spaces_only/config.ts
- x-pack/test/task_manager_claimer_update_by_query/config.ts
- x-pack/platform/test/task_manager_claimer_update_by_query/config.ts
- x-pack/test/ui_capabilities/security_and_spaces/config.ts
- x-pack/test/ui_capabilities/spaces_only/config.ts
- x-pack/test/upgrade_assistant_integration/config.ts

View file

@ -197,13 +197,14 @@ const getPipeline = (filename: string, removeSteps = true) => {
}
if (
(await doAnyChangesMatch([
((await doAnyChangesMatch([
/\.docnav\.json$/,
/\.apidocs\.json$/,
/\.devdocs\.json$/,
/\.mdx$/,
/^dev_docs\/.*(png|gif|jpg|jpeg|webp)$/,
])) ||
])) &&
process.env.GITHUB_PR_TARGET_BRANCH === 'main') ||
GITHUB_PR_LABELS.includes('ci:build-next-docs')
) {
pipeline.push(getPipeline('.buildkite/pipelines/pull_request/check_next_docs.yml'));

View file

@ -9,4 +9,4 @@ echo '--- Installing all packages'
node scripts/functional_tests \
--debug \
--bail \
--config x-pack/test/fleet_packages/config.ts
--config x-pack/platform/test/fleet_packages/config.ts

View file

@ -22,7 +22,7 @@ checkout_and_compile_load_runner
echo "--- Run single apis capacity tests"
cd "$KIBANA_DIR"
node scripts/run_scalability --kibana-install-dir "$KIBANA_BUILD_LOCATION" --journey-path "x-pack/test/scalability/apis"
node scripts/run_scalability --kibana-install-dir "$KIBANA_BUILD_LOCATION" --journey-path "x-pack/platform/test/scalability/apis"
echo "--- Upload test results"
upload_test_results

25
.github/CODEOWNERS vendored
View file

@ -796,7 +796,7 @@ src/platform/packages/shared/kbn-safer-lodash-set @elastic/kibana-security
x-pack/test/security_api_integration/plugins/saml_provider @elastic/kibana-security
x-pack/platform/packages/shared/kbn-sample-parser @elastic/streams-program-team
x-pack/platform/test/plugin_api_integration/plugins/sample_task_plugin @elastic/response-ops
x-pack/test/task_manager_claimer_update_by_query/plugins/sample_task_plugin_mget @elastic/response-ops
x-pack/platform/test/task_manager_claimer_update_by_query/plugins/sample_task_plugin_mget @elastic/response-ops
src/platform/test/plugin_functional/plugins/saved_object_export_transforms @elastic/kibana-core
src/platform/test/plugin_functional/plugins/saved_object_import_warnings @elastic/kibana-core
x-pack/platform/test/saved_object_api_integration/common/plugins/saved_object_test_plugin @elastic/kibana-security
@ -989,7 +989,7 @@ x-pack/solutions/observability/plugins/synthetics @elastic/obs-ux-management-tea
x-pack/packages/kbn-synthetics-private-location @elastic/obs-ux-management-team
x-pack/platform/plugins/private/task_manager_dependencies @elastic/response-ops
x-pack/platform/test/alerting_api_integration/common/plugins/task_manager_fixture @elastic/response-ops
x-pack/test/plugin_api_perf/plugins/task_manager_performance @elastic/response-ops
x-pack/platform/test/plugin_api_perf/plugins/task_manager_performance @elastic/response-ops
x-pack/platform/plugins/shared/task_manager @elastic/response-ops
src/platform/packages/shared/kbn-telemetry @elastic/kibana-core @elastic/obs-ai-assistant
src/platform/plugins/shared/telemetry_collection_manager @elastic/kibana-core
@ -1392,7 +1392,7 @@ packages/kbn-monaco/src/esql @elastic/kibana-esql
/x-pack/test/functional/es_archives/fleet @elastic/fleet
/x-pack/test/api_integration/services/fleet_and_agents.ts @elastic/fleet
/x-pack/test/fleet_api_integration @elastic/fleet
/x-pack/test/fleet_packages @elastic/fleet
/x-pack/platform/test/fleet_packages @elastic/fleet
^/src/platform/test/api_integration/apis/custom_integration/*.ts @elastic/fleet
/x-pack/test/fleet_cypress @elastic/fleet
/x-pack/test/fleet_functional @elastic/fleet
@ -1609,6 +1609,7 @@ x-pack/platform/plugins/shared/ml/server/models/data_recognizer/modules/security
# QA - Appex QA
.buildkite/scout_ci_config.yml @elastic/appex-qa
/x-pack/test/.gitignore @elastic/appex-qa
/x-pack/platform/test/index.d.ts @elastic/appex-qa
/src/platform/packages/shared/kbn-es/src/serverless_resources/project_roles/es/roles.yml @elastic/appex-qa
/src/platform/packages/shared/kbn-es/src/serverless_resources/project_roles/oblt/roles.yml @elastic/appex-qa
/src/platform/packages/shared/kbn-es/src/serverless_resources/project_roles/security/roles.yml @elastic/appex-qa
@ -1676,7 +1677,7 @@ x-pack/platform/plugins/shared/ml/server/models/data_recognizer/modules/security
/x-pack/platform/test/api_integration/services @elastic/appex-qa
/x-pack/platform/test/api_integration/apis/kibana/config.ts @elastic/appex-qa
/x-pack/test/tsconfig.json @elastic/appex-qa
/x-pack/test/load @elastic/appex-qa
/x-pack/platform/test/load @elastic/appex-qa
^/src/platform/test/tsconfig.json @elastic/appex-qa
^/src/platform/test/plugin_functional/services/index.ts @elastic/appex-qa
^/src/platform/test/plugin_functional/README.md @elastic/appex-qa
@ -1727,7 +1728,7 @@ x-pack/platform/plugins/shared/ml/server/models/data_recognizer/modules/security
^/src/platform/test/accessibility/ftr_provider_context.ts @elastic/appex-qa
^/src/platform/test/accessibility/config.ts @elastic/appex-qa
^/src/platform/test/accessibility/apps/index.ts @elastic/appex-qa
/x-pack/test/scalability @elastic/appex-qa
/x-pack/platform/test/scalability @elastic/appex-qa
/src/dev/performance @elastic/appex-qa
/x-pack/test/functional/config.*.* @elastic/appex-qa
/x-pack/platform/test/functional/config.*.* @elastic/appex-qa
@ -1923,9 +1924,9 @@ x-pack/platform/plugins/private/cloud_integrations/cloud_full_story/server/confi
/x-pack/test/functional/es_archives/security @elastic/kibana-security
/x-pack/test/functional/fixtures/kbn_archiver/spaces @elastic/kibana-security
/x-pack/test/functional/fixtures/kbn_archiver/security @elastic/kibana-security
/x-pack/test/ftr_apis/common/lib @elastic/kibana-security
/x-pack/test/ftr_apis/common/fixtures/es_archiver/base_data/space_1.json @elastic/kibana-security # Assigned per only use: https://github.com/elastic/kibana/blob/main/x-pack/test/ftr_apis/security_and_spaces/apis/test_utils.ts#L33
/x-pack/test/ftr_apis/common/fixtures/es_archiver/base_data/default_space.json @elastic/kibana-security # Assigned per only use: https://github.com/elastic/kibana/blob/main/x-pack/test/ftr_apis/security_and_spaces/apis/test_utils.ts#L33
/x-pack/platform/test/ftr_apis/common/lib @elastic/kibana-security
/x-pack/platform/test/ftr_apis/common/fixtures/es_archiver/base_data/space_1.json @elastic/kibana-security # Assigned per only use: https://github.com/elastic/kibana/blob/main/x-pack/platform/test/ftr_apis/security_and_spaces/apis/test_utils.ts#L33
/x-pack/platform/test/ftr_apis/common/fixtures/es_archiver/base_data/default_space.json @elastic/kibana-security # Assigned per only use: https://github.com/elastic/kibana/blob/main/x-pack/platform/test/ftr_apis/security_and_spaces/apis/test_utils.ts#L33
/x-pack/platform/test/api_integration/apis/cloud @elastic/kibana-security # Assigned per https://github.com/elastic/kibana/pull/198444
^/src/platform/test/plugin_functional/snapshots/baseline/hardening @elastic/kibana-security # Assigned per https://github.com/elastic/kibana/pull/190716
^/src/platform/test/functional/page_objects/login_page.ts @elastic/kibana-security
@ -1942,7 +1943,7 @@ x-pack/platform/plugins/private/cloud_integrations/cloud_full_story/server/confi
/x-pack/test/common/services/spaces.ts @elastic/kibana-security
/x-pack/test/api_integration/config_security_*.ts @elastic/kibana-security
/x-pack/test/functional/apps/api_keys @elastic/kibana-security
/x-pack/test/ftr_apis/security_and_spaces @elastic/kibana-security
/x-pack/platform//test/ftr_apis/security_and_spaces @elastic/kibana-security
^/src/platform/test/server_integration/services/supertest.js @elastic/kibana-security @elastic/kibana-core
^/src/platform/test/server_integration/http/ssl @elastic/kibana-security # Assigned per https://github.com/elastic/kibana/pull/53810
^/src/platform/test/server_integration/http/ssl_with_p12 @elastic/kibana-security # Assigned per https://github.com/elastic/kibana/pull/199795#discussion_r1846522206
@ -2000,8 +2001,8 @@ x-pack/platform/plugins/private/cloud_integrations/cloud_full_story/server/confi
# Response Ops team
/x-pack/test/functional/es_archives/rule_registry @elastic/response-ops
/x-pack/test/functional/es_archives/event_log_multiple_indicies @elastic/response-ops
/x-pack/test/functional/es_archives/task_manager* @elastic/response-ops # Assigned per https://github.com/elastic/kibana/blob/assign-response-ops/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.jsonc#L4
/x-pack/test/plugin_api_perf @elastic/response-ops # Assigned per https://github.com/elastic/kibana/blob/assign-response-ops/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.jsonc#L4
/x-pack/test/functional/es_archives/task_manager* @elastic/response-ops # Assigned per https://github.com/elastic/kibana/blob/assign-response-ops/x-pack/platform/test/plugin_api_perf/plugins/task_manager_performance/kibana.jsonc#L4
/x-pack/platform/test/plugin_api_perf @elastic/response-ops # Assigned per https://github.com/elastic/kibana/blob/assign-response-ops/x-pack/platform/test/plugin_api_perf/plugins/task_manager_performance/kibana.jsonc#L4
/x-pack/test/functional/page_objects/maintenance_windows_page.ts @elastic/response-ops
/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/index.ts @elastic/response-ops
/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts @elastic/response-ops
@ -2036,7 +2037,7 @@ x-pack/platform/plugins/private/cloud_integrations/cloud_full_story/server/confi
/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/ @elastic/response-ops
/x-pack/test/functional_with_es_ssl/apps/embeddable_alerts_table/ @elastic/response-ops
/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/ @elastic/response-ops
/x-pack/test/task_manager_claimer_update_by_query/ @elastic/response-ops
/x-pack/platform/test/task_manager_claimer_update_by_query/ @elastic/response-ops
/docs/user/alerting/ @elastic/response-ops
/docs/management/connectors/ @elastic/response-ops
/x-pack/test/cases_api_integration/ @elastic/response-ops

View file

@ -8,6 +8,7 @@ tags: ['kibana', 'onboarding', 'setup', 'performance', 'development', 'telemetry
---
## Overview
It is important to test individual API endpoint for the baseline performance, scalability, or breaking point. If an API doesnt meet performance requirements, it is a bottleneck.
This capacity tests track how response time changes while we slowly increase number of concurrent requests per second.
While using similar load model, we are able to identify how many requests per second each endpoint can hold with response time staying below critical threshold.
@ -15,6 +16,7 @@ While using similar load model, we are able to identify how many requests per se
Capacity API test defines 3 response time thresholds (default ones: 3000, 6000, 12000) in ms. Test results report rps (requests per second) for each threshold.
Test results are reported using EBT in the following format:
```json
{
"_index": "backing-kibana-server-scalability-metrics-000003",
@ -37,7 +39,9 @@ Test results are reported using EBT in the following format:
```
### Adding a new test
Create a new json file in `x-pack/test/scalability/apis` with required properties:
Create a new json file in `x-pack/platform/test/scalability/apis` with required properties:
- **journeyName** is a test name, e.g. `GET /internal/security/session`
- **scalabilitySetup** is used to set load model
- **testData** is used to populate Elasticsearch and Kibana wth test data
@ -47,13 +51,15 @@ Create a new json file in `x-pack/test/scalability/apis` with required propertie
Warmup phase simulates 10 concurrent requests during 30s period and is important to get consistent results in test phase.
Test phase simulates increasing concurrent requests from `minUsersCount` to `maxUsersCount` within `duration` time.
Both `maxUsersCount` and `duration` in test phase should be adjusted for individual endpoint:
- `maxUsersCount` should be reasonable and enough to reach endpoint limits
- `duration` should be long enough to ramp up requests with low pace (1-2 requests per second)
- `maxUsersCount` should be reasonable and enough to reach endpoint limits
- `duration` should be long enough to ramp up requests with low pace (1-2 requests per second)
Example:
```json
{
"journeyName": "GET /internal/security/session",
"journeyName": "GET /internal/security/session",
"scalabilitySetup": {
"warmup": [
{
@ -99,6 +105,7 @@ Example:
```
Override default response time thresholds by adding to `scalabilitySetup`:
```json
"responseTimeThreshold": {
"threshold1": 1000,
@ -108,14 +115,16 @@ Override default response time thresholds by adding to `scalabilitySetup`:
```
### Running api capacity journey locally
Clone [kibana-load-testing](https://github.com/elastic/kibana-load-testing) repo.
Use the Node script from kibana root directory:
`node scripts/run_scalability_cli.js --journey-path x-pack/test/scalability/apis/$YOUR_JOURNEY_NAME.ts`
`node scripts/run_scalability_cli.js --journey-path x-pack/platform/test/scalability/apis/$YOUR_JOURNEY_NAME.ts`
Use `--kibana-install-dir` flag to test build
### Benchmarking performance on CI
In order to keep track on performance metrics stability, api capacity tests are run on main branch with a scheduled interval.
Bare metal machine is used to produce results as stable and reproducible as possible.
@ -128,6 +137,7 @@ RAM: 128 GB
SSD: 1.92 TB Data center Gen4 NVMe
#### Track performance results
APM metrics are reported to [kibana-stats](https://kibana-stats.elastic.dev/) cluster.
You can filter transactions using labels, e.g. `labels.journeyName : "GET /internal/security/session"`

View file

@ -349,6 +349,7 @@ paths:
- $ref: '#/components/schemas/jira_config'
- $ref: '#/components/schemas/genai_azure_config'
- $ref: '#/components/schemas/genai_openai_config'
- $ref: '#/components/schemas/genai_openai_other_config'
- $ref: '#/components/schemas/opsgenie_config'
- $ref: '#/components/schemas/pagerduty_config'
- $ref: '#/components/schemas/sentinelone_config'
@ -65454,12 +65455,30 @@ components:
The URL of the incoming webhook. If you are using the `xpack.actions.allowedHosts` setting, add the hostname to the allowed hosts.
genai_secrets:
title: Connector secrets properties for an OpenAI connector
description: Defines secrets for connectors when type is `.gen-ai`.
description: |
Defines secrets for connectors when type is `.gen-ai`. Supports both API key authentication (OpenAI, Azure OpenAI, and `Other`) and PKI authentication (`Other` provider only). PKI fields must be base64-encoded PEM content.
type: object
properties:
apiKey:
type: string
description: The OpenAI API key.
description: |
The API key for authentication. For OpenAI and Azure OpenAI providers, it is required. For the `Other` provider, it is required if you do not use PKI authentication. With PKI, you can also optionally include an API key if the OpenAI-compatible service supports or requires one.
certificateData:
type: string
description: |
Base64-encoded PEM certificate content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
privateKeyData:
type: string
description: |
Base64-encoded PEM private key content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
caData:
type: string
description: |
Base64-encoded PEM CA certificate content for PKI authentication (Other provider only). Optional.
minLength: 1
required: []
opsgenie_secrets:
title: Connector secrets properties for an Opsgenie connector
required:
@ -65629,6 +65648,52 @@ components:
description: |
A password for HTTP basic authentication. It is applicable only when `usesBasic` is `true`.
type: string
genai_openai_other_config:
title: Connector request properties for an OpenAI connector with Other provider
description: |
Defines properties for connectors when type is `.gen-ai` and the API provider is `Other` (OpenAI-compatible service), including optional PKI authentication.
type: object
required:
- apiProvider
- apiUrl
- defaultModel
properties:
apiProvider:
type: string
description: The OpenAI API provider.
enum:
- Other
apiUrl:
type: string
description: The OpenAI-compatible API endpoint.
defaultModel:
type: string
description: The default model to use for requests.
certificateData:
type: string
description: PEM-encoded certificate content.
minLength: 1
privateKeyData:
type: string
description: PEM-encoded private key content.
minLength: 1
caData:
type: string
description: PEM-encoded CA certificate content.
minLength: 1
verificationMode:
type: string
description: SSL verification mode for PKI authentication.
enum:
- full
- certificate
- none
default: full
headers:
type: object
description: Custom headers to include in requests.
additionalProperties:
type: string
defender_secrets:
title: Connector secrets properties for a Microsoft Defender for Endpoint connector
required:

View file

@ -723,6 +723,7 @@ paths:
- $ref: '#/components/schemas/jira_config'
- $ref: '#/components/schemas/genai_azure_config'
- $ref: '#/components/schemas/genai_openai_config'
- $ref: '#/components/schemas/genai_openai_other_config'
- $ref: '#/components/schemas/opsgenie_config'
- $ref: '#/components/schemas/pagerduty_config'
- $ref: '#/components/schemas/sentinelone_config'
@ -48521,12 +48522,30 @@ components:
The URL of the incoming webhook. If you are using the `xpack.actions.allowedHosts` setting, add the hostname to the allowed hosts.
genai_secrets:
title: Connector secrets properties for an OpenAI connector
description: Defines secrets for connectors when type is `.gen-ai`.
description: |
Defines secrets for connectors when type is `.gen-ai`. Supports both API key authentication (OpenAI, Azure OpenAI, and `Other`) and PKI authentication (`Other` provider only). PKI fields must be base64-encoded PEM content.
type: object
properties:
apiKey:
type: string
description: The OpenAI API key.
description: |
The API key for authentication. For OpenAI and Azure OpenAI providers, it is required. For the `Other` provider, it is required if you do not use PKI authentication. With PKI, you can also optionally include an API key if the OpenAI-compatible service supports or requires one.
certificateData:
type: string
description: |
Base64-encoded PEM certificate content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
privateKeyData:
type: string
description: |
Base64-encoded PEM private key content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
caData:
type: string
description: |
Base64-encoded PEM CA certificate content for PKI authentication (Other provider only). Optional.
minLength: 1
required: []
opsgenie_secrets:
title: Connector secrets properties for an Opsgenie connector
required:
@ -48696,6 +48715,52 @@ components:
description: |
A password for HTTP basic authentication. It is applicable only when `usesBasic` is `true`.
type: string
genai_openai_other_config:
title: Connector request properties for an OpenAI connector with Other provider
description: |
Defines properties for connectors when type is `.gen-ai` and the API provider is `Other` (OpenAI-compatible service), including optional PKI authentication.
type: object
required:
- apiProvider
- apiUrl
- defaultModel
properties:
apiProvider:
type: string
description: The OpenAI API provider.
enum:
- Other
apiUrl:
type: string
description: The OpenAI-compatible API endpoint.
defaultModel:
type: string
description: The default model to use for requests.
certificateData:
type: string
description: PEM-encoded certificate content.
minLength: 1
privateKeyData:
type: string
description: PEM-encoded private key content.
minLength: 1
caData:
type: string
description: PEM-encoded CA certificate content.
minLength: 1
verificationMode:
type: string
description: SSL verification mode for PKI authentication.
enum:
- full
- certificate
- none
default: full
headers:
type: object
description: Custom headers to include in requests.
additionalProperties:
type: string
defender_secrets:
title: Connector secrets properties for a Microsoft Defender for Endpoint connector
required:

View file

@ -164,6 +164,8 @@ actions:
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_azure_config.yaml'
# OpenAI (.gen-ai)
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_openai_config.yaml'
# Other OpenAI (.gen-ai)
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_openai_other_config.yaml'
# Opsgenie (.opsgenie)
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/opsgenie_config.yaml'
# PagerDuty (.pagerduty)

View file

@ -807,7 +807,7 @@
"@kbn/safer-lodash-set": "link:src/platform/packages/shared/kbn-safer-lodash-set",
"@kbn/saml-provider-plugin": "link:x-pack/test/security_api_integration/plugins/saml_provider",
"@kbn/sample-task-plugin": "link:x-pack/platform/test/plugin_api_integration/plugins/sample_task_plugin",
"@kbn/sample-task-plugin-update-by-query": "link:x-pack/test/task_manager_claimer_update_by_query/plugins/sample_task_plugin_mget",
"@kbn/sample-task-plugin-update-by-query": "link:x-pack/platform/test/task_manager_claimer_update_by_query/plugins/sample_task_plugin_mget",
"@kbn/saved-object-export-transforms-plugin": "link:src/platform/test/plugin_functional/plugins/saved_object_export_transforms",
"@kbn/saved-object-import-warnings-plugin": "link:src/platform/test/plugin_functional/plugins/saved_object_import_warnings",
"@kbn/saved-object-test-plugin": "link:x-pack/platform/test/saved_object_api_integration/common/plugins/saved_object_test_plugin",
@ -970,7 +970,7 @@
"@kbn/synthetics-plugin": "link:x-pack/solutions/observability/plugins/synthetics",
"@kbn/task-manager-dependencies-plugin": "link:x-pack/platform/plugins/private/task_manager_dependencies",
"@kbn/task-manager-fixture-plugin": "link:x-pack/platform/test/alerting_api_integration/common/plugins/task_manager_fixture",
"@kbn/task-manager-performance-plugin": "link:x-pack/test/plugin_api_perf/plugins/task_manager_performance",
"@kbn/task-manager-performance-plugin": "link:x-pack/platform/test/plugin_api_perf/plugins/task_manager_performance",
"@kbn/task-manager-plugin": "link:x-pack/platform/plugins/shared/task_manager",
"@kbn/telemetry": "link:src/platform/packages/shared/kbn-telemetry",
"@kbn/telemetry-collection-manager-plugin": "link:src/platform/plugins/shared/telemetry_collection_manager",
@ -1167,7 +1167,7 @@
"deep-freeze-strict": "^1.1.1",
"deepmerge": "^4.3.1",
"del": "^6.1.0",
"diff": "^8.0.1",
"diff": "^8.0.2",
"dotenv": "^16.4.5",
"elastic-apm-node": "^4.13.0",
"email-addresses": "^5.0.0",
@ -1301,6 +1301,8 @@
"redux-thunk": "^2.4.2",
"redux-thunks": "^1.0.0",
"reflect-metadata": "^0.2.1",
"rehype-raw": "5.1.0",
"rehype-sanitize": "4.0.0",
"remark-gfm": "1.0.0",
"remark-parse-no-trim": "^8.0.4",
"remark-stringify": "^8.0.3",

View file

@ -41,7 +41,7 @@ run(
})
: [{ name: path.parse(journeyPath).name, path: journeyPath }];
const skippedFilePath = 'x-pack/test/scalability/disabled_scalability_tests.json';
const skippedFilePath = 'x-pack/platform/test/scalability/disabled_scalability_tests.json';
const skipped: string[] = JSON.parse(
fs.readFileSync(path.resolve(REPO_ROOT, skippedFilePath), 'utf8')
).map((relativePath: string) => path.resolve(REPO_ROOT, relativePath));
@ -89,7 +89,7 @@ run(
cmd: 'node',
args: [
'scripts/functional_tests',
['--config', 'x-pack/test/scalability/config.ts'],
['--config', 'x-pack/platform/test/scalability/config.ts'],
kibanaBuildDir ? ['--kibana-install-dir', kibanaBuildDir] : [],
'--debug',
'--logToFile',

View file

@ -132,7 +132,7 @@ export const reportingCsvExportProvider = ({
name: panelTitle,
exportType: reportType,
label: 'CSV',
icon: 'documents',
icon: 'tableDensityNormal',
generateAssetExport: generateReportingJobCSV,
helpText: (
<FormattedMessage

View file

@ -37,6 +37,9 @@ import {
ALERT_START,
ALERT_STATUS,
ALERT_TIME_RANGE,
ALERT_UPDATED_AT,
ALERT_UPDATED_BY_USER_ID,
ALERT_UPDATED_BY_USER_NAME,
ALERT_URL,
ALERT_UUID,
ALERT_WORKFLOW_ASSIGNEE_IDS,
@ -213,6 +216,21 @@ export const alertFieldMap = {
array: false,
required: false,
},
[ALERT_UPDATED_AT]: {
type: 'date',
array: false,
required: false,
},
[ALERT_UPDATED_BY_USER_ID]: {
type: 'keyword',
array: false,
required: false,
},
[ALERT_UPDATED_BY_USER_NAME]: {
type: 'keyword',
array: false,
required: false,
},
[ALERT_URL]: {
type: 'keyword',
array: false,

View file

@ -107,6 +107,9 @@ const AlertOptional = rt.partial({
'kibana.alert.severity_improving': schemaBoolean,
'kibana.alert.start': schemaDate,
'kibana.alert.time_range': schemaDateRange,
'kibana.alert.updated_at': schemaDate,
'kibana.alert.updated_by.user.id': schemaString,
'kibana.alert.updated_by.user.name': schemaString,
'kibana.alert.url': schemaString,
'kibana.alert.workflow_assignee_ids': schemaStringArray,
'kibana.alert.workflow_status': schemaString,

View file

@ -207,6 +207,9 @@ const SecurityAlertOptional = rt.partial({
})
),
'kibana.alert.time_range': schemaDateRange,
'kibana.alert.updated_at': schemaDate,
'kibana.alert.updated_by.user.id': schemaString,
'kibana.alert.updated_by.user.name': schemaString,
'kibana.alert.url': schemaString,
'kibana.alert.user.criticality_level': schemaString,
'kibana.alert.workflow_assignee_ids': schemaStringArray,

View file

@ -10,7 +10,7 @@
// this file was generated, and should not be edited by hand
// ---------------------------------- WARNING ----------------------------------
import * as rt from 'io-ts';
import type { Either } from 'fp-ts/Either';
import type { Either } from 'fp-ts/lib/Either';
import { AlertSchema } from './alert_schema';
const ISO_DATE_PATTERN = /^d{4}-d{2}-d{2}Td{2}:d{2}:d{2}.d{3}Z$/;
export const IsoDateString = new rt.Type<string, string, unknown>(
@ -107,6 +107,9 @@ const StreamsAlertOptional = rt.partial({
'kibana.alert.severity_improving': schemaBoolean,
'kibana.alert.start': schemaDate,
'kibana.alert.time_range': schemaDateRange,
'kibana.alert.updated_at': schemaDate,
'kibana.alert.updated_by.user.id': schemaString,
'kibana.alert.updated_by.user.name': schemaString,
'kibana.alert.url': schemaString,
'kibana.alert.workflow_assignee_ids': schemaStringArray,
'kibana.alert.workflow_status': schemaString,

View file

@ -382,7 +382,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D
batchReindex: `${KIBANA_DOCS}batch-start-resume-reindex.html`,
indexBlocks: `${ELASTICSEARCH_DOCS}index-modules-blocks.html#index-block-settings`,
remoteReindex: `${ELASTICSEARCH_DOCS}docs-reindex.html#reindex-from-remote`,
unfreezeApi: `${ELASTICSEARCH_DOCS}/unfreeze-index-api.html`,
unfreezeApi: `${ELASTICSEARCH_DOCS}unfreeze-index-api.html`,
reindexWithPipeline: `${ELASTICSEARCH_DOCS}docs-reindex.html#reindex-with-an-ingest-pipeline`,
logsDatastream: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/logs-data-stream.html`,
usingLogsDbIndexModeWithESSecurity: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/detections-logsdb-index-mode-impact.html`,

View file

@ -129,6 +129,8 @@ export const OBSERVABILITY_AI_ASSISTANT_SEARCH_CONNECTOR_INDEX_PATTERN =
'observability:aiAssistantSearchConnectorIndexPattern';
export const OBSERVABILITY_SEARCH_EXCLUDED_DATA_TIERS = 'observability:searchExcludedDataTiers';
export const OBSERVABILITY_ENABLE_STREAMS_UI = 'observability:enableStreamsUI';
export const OBSERVABILITY_STREAMS_ENABLE_SIGNIFICANT_EVENTS =
'observability:streamsEnableSignificantEvents';
// Reporting settings
export const XPACK_REPORTING_CUSTOM_PDF_LOGO_ID = 'xpackReporting:customPdfLogo';

View file

@ -71,6 +71,15 @@ const ALERT_REASON = `${ALERT_NAMESPACE}.reason` as const;
// kibana.alert.start - timestamp when the alert is first active
const ALERT_START = `${ALERT_NAMESPACE}.start` as const;
// kibana.alert.updated_at - timestamp when the alert was last updated
const ALERT_UPDATED_AT = `${ALERT_NAMESPACE}.updated_at` as const;
// kibana.alert.updated_by.user.id - user id of the user that last updated the alert
const ALERT_UPDATED_BY_USER_ID = `${ALERT_NAMESPACE}.updated_by.user.id` as const;
// kibana.alert.updated_by.user.name - user name of the user that last updated the alert
const ALERT_UPDATED_BY_USER_NAME = `${ALERT_NAMESPACE}.updated_by.user.name` as const;
// kibana.alert.status - active/recovered status of alert
const ALERT_STATUS = `${ALERT_NAMESPACE}.status` as const;
@ -163,6 +172,9 @@ export const fields = {
ALERT_RULE_UUID,
ALERT_SEVERITY_IMPROVING,
ALERT_START,
ALERT_UPDATED_AT,
ALERT_UPDATED_BY_USER_ID,
ALERT_UPDATED_BY_USER_NAME,
ALERT_STATUS,
ALERT_TIME_RANGE,
ALERT_URL,
@ -210,6 +222,9 @@ export {
ALERT_RULE_UUID,
ALERT_SEVERITY_IMPROVING,
ALERT_START,
ALERT_UPDATED_AT,
ALERT_UPDATED_BY_USER_ID,
ALERT_UPDATED_BY_USER_NAME,
ALERT_STATUS,
ALERT_TIME_RANGE,
ALERT_URL,

View file

@ -24,6 +24,7 @@ export const OBSERVABILITY_PROJECT_SETTINGS = [
settings.OBSERVABILITY_APM_ENABLE_SERVICE_INVENTORY_TABLE_SEARCH_BAR,
settings.OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID,
settings.OBSERVABILITY_ENABLE_STREAMS_UI,
settings.OBSERVABILITY_STREAMS_ENABLE_SIGNIFICANT_EVENTS,
];
export const OBSERVABILITY_AI_ASSISTANT_PROJECT_SETTINGS = [

View file

@ -42,6 +42,7 @@ describe('TabbedModal', () => {
return (
<EuiFieldText
data-test-subj="log-user-input-field"
placeholder="Placeholder text"
value={state.inputText}
onChange={onChange}
@ -57,7 +58,7 @@ describe('TabbedModal', () => {
},
};
it('renders the modal component', async () => {
it("when a single tab definition is passed it simply renders it's content into the modal component without tabs", async () => {
render(
<TabbedModal
tabs={[tabDefinition]}
@ -66,6 +67,24 @@ describe('TabbedModal', () => {
/>
);
expect(screen.queryByText(tabDefinition.name)).not.toBeInTheDocument();
expect(screen.getByTestId('log-user-input-field')).toBeInTheDocument();
await userEvent.click(await screen.findByTestId(tabDefinition.modalActionBtn!.dataTestSubj));
expect(mockedHandlerFn).toHaveBeenCalled();
});
it('renders the tabbed modal with tabs for tab definition with length greater than 1', async () => {
render(
<TabbedModal
tabs={[tabDefinition, { ...tabDefinition, id: 'anotherTab', name: 'another tab' }]}
defaultSelectedTabId="logUserInput"
onClose={modalOnCloseHandler}
/>
);
expect(screen.queryByText(tabDefinition.name)).toBeInTheDocument();
await userEvent.click(await screen.findByTestId(tabDefinition.modalActionBtn!.dataTestSubj));

View file

@ -127,21 +127,29 @@ const TabbedModalInner: FC<ITabbedModalInner> = ({
);
const renderTabs = useCallback(() => {
return tabs.map((tab, index) => {
return (
<EuiTab
key={index}
onClick={() => onSelectedTabChanged(tab.id)}
isSelected={tab.id === selectedTabId}
disabled={tab.disabled}
prepend={tab.prepend}
append={tab.append}
data-test-subj={tab.id}
>
{tab.name}
</EuiTab>
);
});
if (tabs.length === 1) {
return null;
}
return (
<EuiTabs>
{tabs.map((tab, index) => {
return (
<EuiTab
key={index}
onClick={() => onSelectedTabChanged(tab.id)}
isSelected={tab.id === selectedTabId}
disabled={tab.disabled}
prepend={tab.prepend}
append={tab.append}
data-test-subj={tab.id}
>
{tab.name}
</EuiTab>
);
})}
</EuiTabs>
);
}, [onSelectedTabChanged, selectedTabId, tabs]);
const modalPositionOverrideStyles: React.CSSProperties = {
@ -170,17 +178,22 @@ const TabbedModalInner: FC<ITabbedModalInner> = ({
</EuiModalHeader>
<EuiModalBody>
<Fragment>
<EuiTabs>{renderTabs()}</EuiTabs>
<Fragment>{renderTabs()}</Fragment>
<EuiSpacer size="m" />
{React.createElement(function RenderSelectedTabContent() {
useLayoutEffect(onTabContentRender, []);
return (
<SelectedTabContent
{...{
state: selectedTabState,
dispatch,
}}
/>
<div
css={{ display: 'contents' }}
data-test-subj={`tabbedModal-${selectedTabId}-content`}
>
<SelectedTabContent
{...{
state: selectedTabState,
dispatch,
}}
/>
</div>
);
})}
</Fragment>

View file

@ -659,10 +659,22 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
description: 'Enable the new logs overview component.',
},
},
'cases:incrementalIdDisplay:enabled': {
type: 'boolean',
_meta: {
description: 'Display the incremental id of a case in the relevant pages',
},
},
'observability:enableStreamsUI': {
type: 'boolean',
_meta: {
description: 'Enable Streams UI.',
},
},
'observability:streamsEnableSignificantEvents': {
type: 'boolean',
_meta: {
description: 'Enable significant events in streams.',
},
},
};

View file

@ -171,5 +171,7 @@ export interface UsageStats {
'securitySolution:excludedDataTiersForRuleExecution': string[];
'securitySolution:maxUnassociatedNotes': number;
'observability:searchExcludedDataTiers': string[];
'cases:incrementalIdDisplay:enabled': boolean;
'observability:enableStreamsUI': boolean;
'observability:streamsEnableSignificantEvents': boolean;
}

View file

@ -12,8 +12,9 @@ import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
import { ExportMenu } from './export_popover';
import { ExportMenu } from './export_integrations';
import type { IShareContext } from '../context';
import type { ExportShareConfig } from '../../types';
const mockShareContext: IShareContext = {
shareMenuItems: [
@ -51,7 +52,11 @@ const mockShareContext: IShareContext = {
onClose: jest.fn(),
};
function ExportPopoverRender() {
function ExportPopoverRender({
shareContext = mockShareContext,
}: {
shareContext?: IShareContext;
}) {
const [clickTarget, setClickTarget] = React.useState<HTMLElement | null>();
return (
@ -59,7 +64,7 @@ function ExportPopoverRender() {
{Boolean(clickTarget) && (
<ExportMenu
shareContext={{
...mockShareContext,
...shareContext,
anchorElement: clickTarget!,
}}
/>
@ -69,7 +74,7 @@ function ExportPopoverRender() {
);
}
describe('ExportPopover', () => {
describe('Export Integrations', () => {
it('renders a popover with the list of registered export types', async () => {
const user = userEvent.setup();
@ -83,4 +88,33 @@ describe('ExportPopover', () => {
expect(screen.getByText(label)).toBeInTheDocument();
});
});
it('will invoke the export integrations generateAssetExport config method if it is the singular export type available', async () => {
const user = userEvent.setup();
const singleExportShareContext: IShareContext = {
...mockShareContext,
shareMenuItems: [
{
shareType: 'integration',
groupId: 'export',
id: 'csv',
config: {
icon: 'empty',
label: 'CSV',
generateAssetExport: jest.fn(() => Promise.resolve()),
},
} as unknown as ExportShareConfig,
],
};
render(<ExportPopoverRender shareContext={singleExportShareContext} />);
await user.click(screen.getByText('click me'));
expect(
(singleExportShareContext.shareMenuItems[0] as ExportShareConfig).config.generateAssetExport
).toHaveBeenCalled();
expect(singleExportShareContext.onClose).toHaveBeenCalled();
});
});

View file

@ -190,6 +190,7 @@ function ManagedFlyout({
<EuiCodeBlock
data-test-subj="exportAssetValue"
css={{ overflowWrap: 'break-word' }}
overflowHeight={360}
language={exportIntegration.config.copyAssetURIConfig.contentType}
isCopyable
copyAriaLabel={i18n.translate('share.export.copyPostURLAriaLabel', {
@ -286,34 +287,65 @@ function ExportMenuPopover({ intl }: ExportMenuProps) {
setIsFlyoutVisible(true);
}, []);
useEffect(() => {
// when there is only one share menu item, and no export derivatives registered,
// we want to open the flyout and not the popover
if (
exportIntegrations.length === 1 &&
exportDerivatives.length === 0 &&
!selectedMenuItemMeta
) {
openFlyout(exportIntegrations[0]);
}
}, [exportIntegrations, exportDerivatives, openFlyout, selectedMenuItemMeta]);
const flyoutOnCloseHandler = useCallback(() => {
return exportIntegrations.length === 1 && exportDerivatives.length === 0
? onClose()
: setIsFlyoutVisible(false);
}, [exportDerivatives.length, exportIntegrations.length, onClose]);
const exportIntegrationInteractionHandler = useCallback(
async (menuItem: ExportShareConfig) => {
if (
!menuItem.config.copyAssetURIConfig &&
!menuItem.config.generateAssetComponent &&
menuItem.config.generateAssetExport
) {
await menuItem.config
.generateAssetExport({
intl,
optimizedForPrinting: false,
})
.finally(() => {
onClose();
});
} else {
openFlyout(menuItem);
}
},
[intl, onClose, openFlyout]
);
const flyoutRef = useRef<HTMLDivElement | null>(null);
const canSkipDisplayingPopover = useMemo<boolean>(() => {
// when there is only one export share menu item, and no export derivatives registered,
// we'd like to skip displaying the popover
return exportIntegrations.length === 1 && !exportDerivatives.length;
}, [exportIntegrations, exportDerivatives]);
const flyoutOnCloseHandler = useCallback(() => {
setIsFlyoutVisible(false);
if (canSkipDisplayingPopover) {
onClose();
}
}, [onClose, canSkipDisplayingPopover]);
useEffect(() => {
if (canSkipDisplayingPopover && !selectedMenuItemMeta) {
exportIntegrationInteractionHandler(exportIntegrations[0]);
}
}, [
exportIntegrationInteractionHandler,
exportIntegrations,
onClose,
selectedMenuItemMeta,
canSkipDisplayingPopover,
]);
return (
<Fragment>
<EuiWrappingPopover
isOpen={!isFlyoutVisible}
data-test-subj="exportPopover"
isOpen={!isFlyoutVisible && !canSkipDisplayingPopover}
button={anchorElement!}
closePopover={onClose}
panelPaddingSize="s"
panelProps={{
'data-test-subj': 'exportPopoverPanel',
}}
>
<EuiListGroup flush>
{exportIntegrations.map((menuItem) => (
@ -328,7 +360,7 @@ function ExportMenuPopover({ intl }: ExportMenuProps) {
label={menuItem.config.label}
data-test-subj={`exportMenuItem-${menuItem.config.label}`}
isDisabled={menuItem.config.disabled}
onClick={() => openFlyout(menuItem)}
onClick={exportIntegrationInteractionHandler.bind(null, menuItem)}
/>
</EuiToolTip>
))}
@ -368,6 +400,7 @@ function ExportMenuPopover({ intl }: ExportMenuProps) {
? selectedMenuItem.config.flyoutSizing || {}
: {})}
>
{/* TODO: remove this global style once https://github.com/elastic/eui/issues/8801 is resolved */}
<Global
// @ts-expect-error -- we pass a z-index specifying important so we override the default z-index, so solve a known bug,
// where when `headerZindexLocation` is set to `above`, the popover panel z-index is not high enough

View file

@ -7,4 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { ExportMenu } from './export_popover';
export { ExportMenu } from './export_integrations';

View file

@ -34,7 +34,7 @@ export const ShareMenuTabs = () => {
tabs.push(linkTab);
}
// Embed is disabled in the serverless offering, hence the need to check that we received it
// Embed is disabled in the serverless offering, hence the need to check if the embed tab should be shown
if (
shareMenuItems.some(({ shareType }) => shareType === 'embed') &&
!objectTypeMeta?.config?.embed?.disabled

View file

@ -16,7 +16,7 @@ import { ShowShareMenuOptions } from '../types';
import { ShareRegistry } from './share_menu_registry';
import type { ShareConfigs } from '../types';
import { ShareMenu } from '../components/share_tabs';
import { ExportMenu } from '../components/export_popover';
import { ExportMenu } from '../components/export_integrations';
interface ShareMenuManagerStartDeps {
core: CoreStart;

View file

@ -11273,11 +11273,23 @@
"description": "Enable the new logs overview component."
}
},
"cases:incrementalIdDisplay:enabled": {
"type": "boolean",
"_meta": {
"description": "Display the incremental id of a case in the relevant pages"
}
},
"observability:enableStreamsUI": {
"type": "boolean",
"_meta": {
"description": "Enable Streams UI."
}
},
"observability:streamsEnableSignificantEvents": {
"type": "boolean",
"_meta": {
"description": "Enable significant events in streams."
}
}
}
},

View file

@ -24,7 +24,7 @@ export class ExportPageObject extends FtrService {
}
async isExportPopoverOpen() {
return await this.testSubjects.exists('exportPopover');
return await this.testSubjects.exists('exportPopoverPanel');
}
async isPopoverItemEnabled(label: string) {

View file

@ -228,6 +228,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.cases.files.maxSize (number?)',
'xpack.cases.markdownPlugins.lens (boolean?)',
'xpack.cases.stack.enabled (boolean?)',
'xpack.cases.incrementalId.enabled (boolean?)',
'xpack.ccr.ui.enabled (boolean?)',
'xpack.cloud.base_url (string?)',
'xpack.cloud.cname (string?)',

View file

@ -1586,8 +1586,8 @@
"@kbn/sample-log-parser/*": ["x-pack/platform/packages/shared/kbn-sample-parser/*"],
"@kbn/sample-task-plugin": ["x-pack/platform/test/plugin_api_integration/plugins/sample_task_plugin"],
"@kbn/sample-task-plugin/*": ["x-pack/platform/test/plugin_api_integration/plugins/sample_task_plugin/*"],
"@kbn/sample-task-plugin-update-by-query": ["x-pack/test/task_manager_claimer_update_by_query/plugins/sample_task_plugin_mget"],
"@kbn/sample-task-plugin-update-by-query/*": ["x-pack/test/task_manager_claimer_update_by_query/plugins/sample_task_plugin_mget/*"],
"@kbn/sample-task-plugin-update-by-query": ["x-pack/platform/test/task_manager_claimer_update_by_query/plugins/sample_task_plugin_mget"],
"@kbn/sample-task-plugin-update-by-query/*": ["x-pack/platform/test/task_manager_claimer_update_by_query/plugins/sample_task_plugin_mget/*"],
"@kbn/saved-object-export-transforms-plugin": ["src/platform/test/plugin_functional/plugins/saved_object_export_transforms"],
"@kbn/saved-object-export-transforms-plugin/*": ["src/platform/test/plugin_functional/plugins/saved_object_export_transforms/*"],
"@kbn/saved-object-import-warnings-plugin": ["src/platform/test/plugin_functional/plugins/saved_object_import_warnings"],
@ -1972,8 +1972,8 @@
"@kbn/task-manager-dependencies-plugin/*": ["x-pack/platform/plugins/private/task_manager_dependencies/*"],
"@kbn/task-manager-fixture-plugin": ["x-pack/platform/test/alerting_api_integration/common/plugins/task_manager_fixture"],
"@kbn/task-manager-fixture-plugin/*": ["x-pack/platform/test/alerting_api_integration/common/plugins/task_manager_fixture/*"],
"@kbn/task-manager-performance-plugin": ["x-pack/test/plugin_api_perf/plugins/task_manager_performance"],
"@kbn/task-manager-performance-plugin/*": ["x-pack/test/plugin_api_perf/plugins/task_manager_performance/*"],
"@kbn/task-manager-performance-plugin": ["x-pack/platform/test/plugin_api_perf/plugins/task_manager_performance"],
"@kbn/task-manager-performance-plugin/*": ["x-pack/platform/test/plugin_api_perf/plugins/task_manager_performance/*"],
"@kbn/task-manager-plugin": ["x-pack/platform/plugins/shared/task_manager"],
"@kbn/task-manager-plugin/*": ["x-pack/platform/plugins/shared/task_manager/*"],
"@kbn/telemetry": ["src/platform/packages/shared/kbn-telemetry"],

View file

@ -31,6 +31,7 @@ export {
type FunctionCallingMode,
type ToolChoice,
type ChatCompleteAPI,
type ChatCompleteAPIResponse,
type ChatCompleteOptions,
type ChatCompleteCompositeResponse,
type ChatCompletionTokenCountEvent,
@ -43,7 +44,6 @@ export {
type ChatCompleteRetryConfiguration,
type ChatCompletionTokenCount,
type BoundChatCompleteAPI,
type BoundChatCompleteOptions,
type UnboundChatCompleteOptions,
withoutTokenCountEvents,
withoutChunkEvents,
@ -75,7 +75,6 @@ export {
type Output,
type OutputEvent,
type BoundOutputAPI,
type BoundOutputOptions,
type UnboundOutputOptions,
isOutputCompleteEvent,
isOutputUpdateEvent,
@ -139,7 +138,6 @@ export { type Model, ModelFamily, ModelPlatform, ModelProvider } from './src/mod
export {
type BoundPromptAPI,
type BoundPromptOptions,
type Prompt,
type PromptAPI,
type PromptCompositeResponse,
@ -152,3 +150,5 @@ export {
type UnboundPromptOptions,
createPrompt,
} from './src/prompt';
export { type BoundOptions, type UnboundOptions, bindApi } from './src/bind';

View file

@ -0,0 +1,45 @@
/*
* 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 { FunctionCallingMode } from '../chat_complete';
export interface BoundOptions {
functionCalling?: FunctionCallingMode;
connectorId: string;
}
type BoundOptionKey = keyof BoundOptions;
type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never;
export type UnboundOptions<TOptions extends BoundOptions> = DistributiveOmit<
TOptions,
BoundOptionKey
>;
type BindableAPI = (options: any, ...rest: any[]) => any;
type BoundAPI<F extends BindableAPI> = F extends (options: infer O, ...rest: infer R) => infer Ret
? O extends BoundOptions
? (options: UnboundOptions<O>, ...rest: R) => Ret
: never
: never;
export function bindApi<T extends BindableAPI, U extends BoundOptions>(
api: T,
boundParams: U
): BoundAPI<T>;
export function bindApi(api: BindableAPI, boundParams: BoundOptions) {
const { functionCalling, connectorId } = boundParams;
return (params: UnboundOptions<BoundOptions>) => {
return api({
...params,
functionCalling,
connectorId,
});
};
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { type BoundOptions, type UnboundOptions, bindApi } from './bind_api';

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import type { Overwrite } from 'utility-types';
import type { Observable } from 'rxjs';
import type { ToolCallsOf, ToolOptions } from './tools';
import type { ToolCallsOf, ToolChoiceType, ToolOptions } from './tools';
import type { Message } from './messages';
import type { ChatCompletionEvent, ChatCompletionTokenCount } from './events';
import type { ChatCompleteMetadata } from './metadata';
@ -55,33 +56,36 @@ import type { ChatCompleteMetadata } from './metadata';
* });
* ```
*/
export type ChatCompleteAPI = <
TToolOptions extends ToolOptions = ToolOptions,
TStream extends boolean = false
>(
options: ChatCompleteOptions<TToolOptions, TStream>
) => ChatCompleteCompositeResponse<TToolOptions, TStream>;
export interface DefaultChatCompleteOptions {
stream: false;
tools: {};
toolChoice: ToolChoiceType.auto;
}
type ChatCompleteCompositeResponseOptions = Pick<
ChatCompleteOptions,
'stream' | 'tools' | 'toolChoice'
>;
type ChatCompleteResponseOptions = Pick<ChatCompleteOptions, 'tools' | 'toolChoice'>;
export type ChatCompleteAPIResponse<TOptions extends ChatCompleteOptions = ChatCompleteOptions> =
ChatCompleteCompositeResponse<Overwrite<DefaultChatCompleteOptions, TOptions>>;
export type ChatCompleteAPI = <TOptions extends ChatCompleteOptions>(
options: TOptions
) => ChatCompleteAPIResponse<TOptions>;
/**
* Options used to call the {@link ChatCompleteAPI}
*/
export type ChatCompleteOptions<
TToolOptions extends ToolOptions = ToolOptions,
TStream extends boolean = false
> = {
export type ChatCompleteOptions = {
/**
* The ID of the connector to use.
* Must be an inference connector, or an error will be thrown.
*/
connectorId: string;
/**
* Set to true to enable streaming, which will change the API response type from
* a single {@link ChatCompleteResponse} promise
* to a {@link ChatCompleteStreamResponse} event observable.
*
* Defaults to false.
*/
stream?: TStream;
/**
* Optional system message for the LLM.
*/
@ -126,7 +130,15 @@ export type ChatCompleteOptions<
* Note that defaults are very fine, so only use this if you really have a reason to do so.
*/
retryConfiguration?: ChatCompleteRetryConfiguration;
} & TToolOptions;
/**
* Set to true to enable streaming, which will change the API response type from
* a single {@link ChatCompleteResponse} promise
* to a {@link ChatCompleteStreamResponse} event observable.
*
* Defaults to false.
*/
stream?: boolean;
} & ToolOptions;
export interface ChatCompleteRetryConfiguration {
/**
@ -160,25 +172,26 @@ export interface ChatCompleteRetryConfiguration {
* whether API was called with stream mode enabled or not.
*/
export type ChatCompleteCompositeResponse<
TToolOptions extends ToolOptions = ToolOptions,
TStream extends boolean = false
> = TStream extends true
? ChatCompleteStreamResponse<TToolOptions>
: Promise<ChatCompleteResponse<TToolOptions>>;
TOptions extends ChatCompleteCompositeResponseOptions = ChatCompleteCompositeResponseOptions
> =
| (true extends TOptions['stream'] ? ChatCompleteStreamResponse<TOptions> : never)
| (false extends TOptions['stream'] ? Promise<ChatCompleteResponse<TOptions>> : never);
/**
* Response from the {@link ChatCompleteAPI} when streaming is enabled.
*
* Observable of {@link ChatCompletionEvent}
*/
export type ChatCompleteStreamResponse<TToolOptions extends ToolOptions = ToolOptions> = Observable<
ChatCompletionEvent<TToolOptions>
>;
export type ChatCompleteStreamResponse<
TOptions extends ChatCompleteResponseOptions = ChatCompleteResponseOptions
> = Observable<ChatCompletionEvent<TOptions>>;
/**
* Response from the {@link ChatCompleteAPI} when streaming is not enabled.
*/
export interface ChatCompleteResponse<TToolOptions extends ToolOptions = ToolOptions> {
export interface ChatCompleteResponse<
TOptions extends ChatCompleteResponseOptions = ChatCompleteResponseOptions
> {
/**
* The text content of the LLM response.
*/
@ -186,7 +199,7 @@ export interface ChatCompleteResponse<TToolOptions extends ToolOptions = ToolOpt
/**
* The eventual tool calls performed by the LLM.
*/
toolCalls: ToolCallsOf<TToolOptions>['toolCalls'];
toolCalls: ToolCallsOf<TOptions>['toolCalls'];
/**
* Token counts
*/

View file

@ -5,31 +5,17 @@
* 2.0.
*/
import type { ChatCompleteOptions, ChatCompleteCompositeResponse } from './api';
import type { ToolOptions } from './tools';
/**
* Static options used to call the {@link BoundChatCompleteAPI}
*/
export type BoundChatCompleteOptions<
TToolOptions extends ToolOptions = ToolOptions,
TStream extends boolean = false
> = Pick<ChatCompleteOptions<TToolOptions, TStream>, 'connectorId' | 'functionCalling'>;
import { BoundOptions, UnboundOptions } from '../bind/bind_api';
import type { ChatCompleteOptions, ChatCompleteAPIResponse } from './api';
/**
* Options used to call the {@link BoundChatCompleteAPI}
*/
export type UnboundChatCompleteOptions<
TToolOptions extends ToolOptions = ToolOptions,
TStream extends boolean = false
> = Omit<ChatCompleteOptions<TToolOptions, TStream>, 'connectorId' | 'functionCalling'>;
export type UnboundChatCompleteOptions = UnboundOptions<ChatCompleteOptions>;
/**
* Version of {@link ChatCompleteAPI} that got pre-bound to a set of static parameters
*/
export type BoundChatCompleteAPI = <
TToolOptions extends ToolOptions = ToolOptions,
TStream extends boolean = false
>(
options: UnboundChatCompleteOptions<TToolOptions, TStream>
) => ChatCompleteCompositeResponse<TToolOptions, TStream>;
export type BoundChatCompleteAPI = <TChatCompleteOptions extends UnboundChatCompleteOptions>(
options: TChatCompleteOptions
) => ChatCompleteAPIResponse<TChatCompleteOptions & BoundOptions>;

View file

@ -8,17 +8,14 @@
export type {
ChatCompleteCompositeResponse,
ChatCompleteAPI,
ChatCompleteAPIResponse,
ChatCompleteOptions,
FunctionCallingMode,
ChatCompleteStreamResponse,
ChatCompleteResponse,
ChatCompleteRetryConfiguration,
} from './api';
export type {
BoundChatCompleteAPI,
BoundChatCompleteOptions,
UnboundChatCompleteOptions,
} from './bound_api';
export type { BoundChatCompleteAPI, UnboundChatCompleteOptions } from './bound_api';
export {
ChatCompletionEventType,
type ChatCompletionMessageEvent,

View file

@ -39,12 +39,14 @@ export type ToolCallbacksOf<TToolOptions extends ToolOptions> = TToolOptions ext
*/
export type ToolResponsesOf<TTools extends Record<string, ToolDefinition> | undefined> =
TTools extends Record<string, ToolDefinition>
? Array<
ValuesType<{
[TName in keyof TTools & string]: ToolCall<TName, ToolResponseOf<TTools[TName]>>;
}>
>
: never[];
? keyof TTools extends never
? []
: Array<
ValuesType<{
[TName in keyof TTools & string]: ToolCall<TName, ToolResponseOf<TTools[TName]>>;
}>
>
: [];
/**
* Utility type to infer the tool call response shape.
@ -113,7 +115,7 @@ export type ToolCallsOf<TToolOptions extends ToolOptions> = TToolOptions extends
: {
toolCalls: ToolResponsesOf<ToolsOfChoice<TToolOptions>>;
}
: { toolCalls: never };
: { toolCalls: [] };
/**
* Represents a tool call from the LLM before correctly converted to the schema type.

View file

@ -0,0 +1,212 @@
/*
* 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 { expectAssignable, expectType } from 'tsd';
import type {
ChatCompleteAPI,
ChatCompleteCompositeResponse,
ChatCompleteResponse,
ChatCompleteStreamResponse,
} from '../api';
import { Message, MessageRole } from '../messages';
import { ToolChoiceType, ToolDefinition, ToolResponseOf } from '../tools';
declare const mockApi: ChatCompleteAPI;
const minimalMessages: Message[] = [{ role: MessageRole.User, content: 'hello' }];
const getWeatherToolDefinition = {
schema: {
type: 'object',
properties: { location: { type: 'string' } },
required: ['location'],
},
description: 'Get weather',
} satisfies ToolDefinition;
const getStockPriceToolDefinition = {
schema: {
type: 'object',
properties: { location: { type: 'string' } },
required: ['location'],
},
description: 'Get weather',
} satisfies ToolDefinition;
const myTools = {
get_weather: getWeatherToolDefinition,
get_stock_price: getStockPriceToolDefinition,
};
type MyTools = typeof myTools;
/**
* No specified settings
*/
declare const defaultToolResponse: ChatCompleteResponse<{}>;
expectType<{ content: string; toolCalls: [] }>(defaultToolResponse);
/**
* No tools
*/
declare const emptyToolResponse: ChatCompleteResponse<{
tools: {};
toolChoice: ToolChoiceType.auto;
}>;
expectType<{ content: string; toolCalls: [] }>(emptyToolResponse);
/**
* Defined tools
*/
declare const specificToolResponse: ChatCompleteResponse<{
tools: MyTools;
toolChoice: ToolChoiceType.auto;
}>;
expectType<{
content: string;
toolCalls: Array<{
toolCallId: string;
function: {
name: keyof MyTools;
arguments: ToolResponseOf<MyTools['get_stock_price'] | MyTools['get_weather']>;
};
}>;
}>(specificToolResponse);
if (specificToolResponse.toolCalls && specificToolResponse.toolCalls.length > 0) {
const firstToolCall = specificToolResponse.toolCalls[0];
expectAssignable<'get_weather' | 'get_stock_price'>(firstToolCall.function.name);
}
/**
* stream: false = no streaming
*/
type CompositeNonStream = ChatCompleteCompositeResponse<{
stream: false;
tools: MyTools;
toolChoice: ToolChoiceType.auto;
}>;
expectType<Promise<ChatCompleteResponse<{ tools: MyTools; toolChoice: ToolChoiceType.auto }>>>(
{} as CompositeNonStream
);
/**
* stream: false = streaming
*/
type CompositeStream = ChatCompleteCompositeResponse<{
stream: true;
tools: MyTools;
toolChoice: ToolChoiceType.auto;
}>;
/**
* stream: boolean = union
*/
expectType<ChatCompleteStreamResponse<{ tools: MyTools; toolChoice: ToolChoiceType.auto }>>(
{} as CompositeStream
);
type CompositeBooleanStream = ChatCompleteCompositeResponse<{
stream: boolean;
tools: MyTools;
toolChoice: ToolChoiceType.auto;
}>;
expectAssignable<
| Promise<ChatCompleteResponse<{ tools: MyTools; toolChoice: ToolChoiceType.auto }>>
| ChatCompleteStreamResponse<{ tools: MyTools; toolChoice: ToolChoiceType.auto }>
>({} as CompositeBooleanStream);
/**
* tools inference for runtime code
*/
const resWithTools = mockApi({
connectorId: 'c1',
messages: minimalMessages,
tools: { get_weather: getWeatherToolDefinition },
});
// defaults to non-streaming
expectType<
Promise<
ChatCompleteResponse<{
tools: { get_weather: typeof getWeatherToolDefinition };
toolChoice: ToolChoiceType.auto;
}>
>
>(resWithTools);
resWithTools.then((r) => {
if (r.toolCalls) {
expectType<'get_weather'>(r.toolCalls[0].function.name);
}
});
/**
* tools inference for runtime code + stream: true
*/
const resStreamWithTools = mockApi({
connectorId: 'c1',
messages: minimalMessages,
stream: true,
tools: { get_weather: getWeatherToolDefinition },
});
expectType<
ChatCompleteStreamResponse<{
tools: { get_weather: typeof getWeatherToolDefinition };
toolChoice: ToolChoiceType.auto;
}>
>(resStreamWithTools);
/**
* toolChoice = none should be toolCalls:[]
*/
const resToolChoiceNone = mockApi({
connectorId: 'c1',
messages: minimalMessages,
tools: { get_weather: getWeatherToolDefinition },
toolChoice: ToolChoiceType.none,
});
resToolChoiceNone.then((r) => {
expectType<[]>(r.toolCalls);
});
/**
* specific tool choice = only specified tool is typed
*/
const resToolChoiceSpecific = mockApi({
connectorId: 'c1',
messages: minimalMessages,
tools: myTools,
toolChoice: { type: 'function', function: 'get_weather' as const },
});
expectType<
Promise<
ChatCompleteResponse<{
tools: MyTools;
toolChoice: { type: 'function'; function: 'get_weather' };
}>
>
>(resToolChoiceSpecific);
resToolChoiceSpecific.then((r) => {
if (r.toolCalls) {
expectAssignable<
Array<{
toolCallId: string;
function: {
name: 'get_weather';
arguments: ToolResponseOf<typeof getWeatherToolDefinition>;
};
}>
>(r.toolCalls);
}
});

View file

@ -7,15 +7,7 @@
import type { OutputOptions, OutputCompositeResponse } from './api';
import type { ToolSchema } from '../chat_complete/tool_schema';
/**
* Static options used to call the {@link BoundOutputAPI}
*/
export type BoundOutputOptions<
TId extends string = string,
TOutputSchema extends ToolSchema | undefined = ToolSchema | undefined,
TStream extends boolean = false
> = Pick<OutputOptions<TId, TOutputSchema, TStream>, 'connectorId' | 'functionCalling'>;
import { UnboundOptions } from '../bind/bind_api';
/**
* Options used to call the {@link BoundOutputAPI}
@ -24,7 +16,7 @@ export type UnboundOutputOptions<
TId extends string = string,
TOutputSchema extends ToolSchema | undefined = ToolSchema | undefined,
TStream extends boolean = false
> = Omit<OutputOptions<TId, TOutputSchema, TStream>, 'connectorId' | 'functionCalling'>;
> = UnboundOptions<OutputOptions<TId, TOutputSchema, TStream>>;
/**
* Version of {@link OutputAPI} that got pre-bound to a set of static parameters

View file

@ -12,7 +12,7 @@ export type {
OutputResponse,
OutputStreamResponse,
} from './api';
export type { BoundOutputAPI, BoundOutputOptions, UnboundOutputOptions } from './bound_api';
export type { BoundOutputAPI, UnboundOutputOptions } from './bound_api';
export {
OutputEventType,
type OutputCompleteEvent,

View file

@ -6,33 +6,30 @@
*/
import { z } from '@kbn/zod';
import { Optional } from 'utility-types';
import { Overwrite, Assign } from 'utility-types';
import {
ChatCompleteOptions,
ChatCompleteResponse,
ChatCompleteStreamResponse,
Message,
ToolChoice,
ToolDefinition,
ToolOptions,
} from '../chat_complete';
import { Prompt, ToolOptionsOfPrompt } from './types';
import {
ChatCompleteAPIResponse,
ChatCompleteCompositeResponse,
ChatCompleteResponse,
ChatCompleteStreamResponse,
} from '../chat_complete/api';
/**
* Generate a response with the LLM based on a structured Prompt.
*/
export type PromptAPI = <
TPrompt extends Prompt = Prompt,
TOtherOptions extends PromptOptions<TPrompt> = PromptOptions<TPrompt>
>(
options: TOtherOptions & { prompt: TPrompt }
) => PromptCompositeResponse<{ prompt: TPrompt } & TOtherOptions>;
type PromptChatCompleteOptions = Omit<ChatCompleteOptions, 'messages' | 'system'>;
/**
* Options for the {@link PromptAPI}
*/
export interface PromptOptions<TPrompt extends Prompt = Prompt>
extends Optional<Omit<ChatCompleteOptions, 'messages' | 'system' | 'stream'>, 'temperature'> {
export interface PromptOptions<TPrompt extends Prompt = Prompt> extends PromptChatCompleteOptions {
prompt: TPrompt;
input: z.input<TPrompt['input']>;
stream?: boolean;
prevMessages?: Message[];
}
@ -42,20 +39,52 @@ export interface PromptOptions<TPrompt extends Prompt = Prompt>
* whether API was called with stream mode enabled or not.
*/
export type PromptCompositeResponse<TPromptOptions extends PromptOptions = PromptOptions> =
TPromptOptions['stream'] extends true
? PromptStreamResponse<TPromptOptions['prompt']>
: Promise<PromptResponse<TPromptOptions['prompt']>>;
ChatCompleteCompositeResponse<
Omit<TPromptOptions, 'tools' | 'toolChoice'> &
MergeToolOptions<ToolOptionsOfPrompt<TPromptOptions['prompt']>, TPromptOptions>
>;
type MergeToolOptions<TLeft extends ToolOptions, TRight extends ToolOptions> = Overwrite<
Pick<TLeft, 'tools' | 'toolChoice'>,
{
toolChoice: TRight['toolChoice'] extends ToolChoice
? TRight['toolChoice']
: TLeft['toolChoice'];
tools: TLeft['tools'] extends Record<string, ToolDefinition>
? TRight['tools'] extends Record<string, ToolDefinition>
? Assign<TLeft['tools'], TRight['tools']>
: TLeft['tools']
: TRight['tools'] extends Record<string, ToolDefinition>
? TRight['tools']
: {};
}
>;
type ChatCompleteOptionsOfPromptOptions<TPromptOptions extends PromptOptions = PromptOptions> = {
messages: Message[];
} & Omit<TPromptOptions, 'tools' | 'toolChoice'> &
MergeToolOptions<ToolOptionsOfPrompt<TPromptOptions['prompt']>, TPromptOptions>;
export type PromptResponseOf<
TPrompt extends Prompt,
TStream extends boolean = false
> = PromptCompositeResponse<Overwrite<PromptOptions<TPrompt>, { stream: TStream }>>;
export type PromptAPIResponse<TPromptOptions extends PromptOptions = PromptOptions> =
ChatCompleteAPIResponse<ChatCompleteOptionsOfPromptOptions<TPromptOptions>>;
/**
* Response from the {@link PromptAPI} when streaming is not enabled.
*/
export type PromptResponse<TPrompt extends Prompt = Prompt> = ChatCompleteResponse<
ToolOptionsOfPrompt<TPrompt>
>;
export type PromptResponse<TPromptOptions extends PromptOptions = PromptOptions> =
ChatCompleteResponse<ChatCompleteOptionsOfPromptOptions<TPromptOptions>>;
/**
* Response from the {@link PromptAPI} in streaming mode.
*/
export type PromptStreamResponse<TPrompt extends Prompt = Prompt> = ChatCompleteStreamResponse<
ToolOptionsOfPrompt<TPrompt>
>;
export type PromptStreamResponse<TPromptOptions extends PromptOptions = PromptOptions> =
ChatCompleteStreamResponse<ChatCompleteOptionsOfPromptOptions<TPromptOptions>>;
export type PromptAPI = <TPrompt extends Prompt, TPromptOptions extends PromptOptions>(
options: PromptOptions<TPrompt> & TPromptOptions
) => PromptAPIResponse<TPromptOptions>;

View file

@ -5,31 +5,19 @@
* 2.0.
*/
import type { PromptOptions, PromptCompositeResponse } from './api';
import { BoundOptions, UnboundOptions } from '../bind/bind_api';
import type { PromptOptions, PromptAPIResponse } from './api';
import { Prompt } from './types';
/**
* Static options used to call the {@link BoundPromptAPI}
*/
export type BoundPromptOptions<TPromptOptions extends PromptOptions = PromptOptions> = Pick<
PromptOptions<TPromptOptions['prompt']>,
'connectorId' | 'functionCalling'
>;
/**
* Options used to call the {@link BoundPromptAPI}
*/
export type UnboundPromptOptions<TPromptOptions extends PromptOptions = PromptOptions> = Omit<
PromptOptions<TPromptOptions['prompt']>,
'connectorId' | 'functionCalling'
>;
export type UnboundPromptOptions<TPromptOptions extends PromptOptions = PromptOptions> =
UnboundOptions<TPromptOptions>;
/**
* Version of {@link PromptAPI} that got pre-bound to a set of static parameters
*/
export type BoundPromptAPI = <
TPrompt extends Prompt = Prompt,
TPromptOptions extends PromptOptions<TPrompt> = PromptOptions<TPrompt>
>(
options: UnboundPromptOptions<TPromptOptions & { prompt: TPrompt }>
) => PromptCompositeResponse<TPromptOptions & { prompt: TPrompt }>;
export type BoundPromptAPI = <TPrompt extends Prompt, TPromptOptions extends UnboundPromptOptions>(
options: { prompt: TPrompt } & TPromptOptions
) => PromptAPIResponse<BoundOptions & TPromptOptions>;

View file

@ -12,5 +12,5 @@ export type {
PromptResponse,
PromptStreamResponse,
} from './api';
export type { BoundPromptAPI, BoundPromptOptions, UnboundPromptOptions } from './bound_api';
export type { BoundPromptAPI, UnboundPromptOptions } from './bound_api';
export type { Prompt, PromptFactory, PromptVersion, ToolOptionsOfPrompt } from './types';

View file

@ -0,0 +1,323 @@
/*
* 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 { expectAssignable, expectType } from 'tsd';
import { z } from '@kbn/zod';
import type {
PromptAPI,
PromptCompositeResponse,
PromptOptions,
PromptResponse,
PromptResponseOf,
PromptStreamResponse,
} from '../api';
import type { Prompt, PromptVersion, MustachePromptTemplate } from '../types';
import {
ToolCallsOf,
ToolChoiceType,
ToolDefinition,
ToolResponseOf,
} from '../../chat_complete/tools';
declare const mockApi: PromptAPI;
const MinimalPromptInputSchema = z.object({
query: z.string(),
});
type MinimalPromptInput = z.input<typeof MinimalPromptInputSchema>;
const minimalInput: MinimalPromptInput = { query: 'test' };
const mustacheTemplate: MustachePromptTemplate = {
mustache: {
template: 'User query: {{query}}',
},
};
const getWeatherToolDefinition = {
schema: {
type: 'object',
properties: { location: { type: 'string' } },
required: ['location'],
},
description: 'Get weather',
} satisfies ToolDefinition;
const getStockPriceToolDefinition = {
schema: {
type: 'object',
properties: { ticker: { type: 'string' } },
required: ['ticker'],
},
description: 'Get stock price',
} satisfies ToolDefinition;
const promptTools = {
get_weather: getWeatherToolDefinition,
get_stock_price: getStockPriceToolDefinition,
};
type PromptTools = typeof promptTools;
/**
* Prompts for testing
*/
const promptNoTools = {
name: 'NoToolsPrompt',
description: 'A prompt with no tools defined in its version.',
input: MinimalPromptInputSchema,
versions: [
{
template: mustacheTemplate,
// No tools or toolChoice here
} satisfies PromptVersion,
],
} satisfies Prompt<MinimalPromptInput>;
type PromptNoTools = typeof promptNoTools;
const promptWithToolsAuto = {
name: 'WithToolsAutoPrompt',
description: 'A prompt with tools and toolChoice:auto.',
input: MinimalPromptInputSchema,
versions: [
{
template: mustacheTemplate,
tools: promptTools,
toolChoice: ToolChoiceType.auto,
} satisfies PromptVersion<{ tools: PromptTools; toolChoice: ToolChoiceType.auto }>,
],
} satisfies Prompt<MinimalPromptInput>;
type PromptWithToolsAuto = typeof promptWithToolsAuto;
const promptWithToolsNone = {
name: 'WithToolsNonePrompt',
description: 'A prompt with tools and toolChoice:none.',
input: MinimalPromptInputSchema,
versions: [
{
template: mustacheTemplate,
tools: promptTools,
toolChoice: ToolChoiceType.none,
} satisfies PromptVersion<{ tools: PromptTools; toolChoice: ToolChoiceType.none }>,
],
} satisfies Prompt<MinimalPromptInput>;
const promptWithGetWeatherToolChoice = {
name: 'WithToolsSpecificPrompt',
description: 'A prompt with tools and specific toolChoice.',
input: MinimalPromptInputSchema,
versions: [
{
template: mustacheTemplate,
tools: promptTools,
toolChoice: { function: 'get_weather' as const },
} satisfies PromptVersion<{
tools: PromptTools;
toolChoice: { function: 'get_weather' };
}>,
],
} satisfies Prompt<MinimalPromptInput>;
/**
* Test PromptResponse type
*/
// No tools in prompt version
declare const noToolsPromptResponse: Awaited<PromptResponseOf<PromptNoTools>>;
expectType<{ content: string; toolCalls: [] }>(noToolsPromptResponse);
// With tools defined in prompt version
declare const specificToolPromptResponse: Awaited<PromptResponseOf<PromptWithToolsAuto>>;
expectType<{
content: string;
toolCalls: Array<{
toolCallId: string;
function: {
name: keyof PromptTools;
arguments: ToolResponseOf<PromptTools[keyof PromptTools]>;
};
}>;
}>(specificToolPromptResponse);
if (specificToolPromptResponse.toolCalls && specificToolPromptResponse.toolCalls.length > 0) {
const firstToolCall = specificToolPromptResponse.toolCalls[0];
expectAssignable<'get_weather' | 'get_stock_price'>(firstToolCall.function.name);
}
/**
* Test PromptCompositeResponse type
*/
type PromptOptsWithToolsAuto = PromptOptions<PromptWithToolsAuto>;
// stream: false
type CompositeNonStream = PromptCompositeResponse<PromptOptsWithToolsAuto & { stream: false }>;
expectType<PromptResponseOf<PromptWithToolsAuto>>({} as CompositeNonStream);
// stream: true
type PromptOptsWithToolsAutoStreamResponse = PromptStreamResponse<PromptOptsWithToolsAuto>;
expectType<PromptResponseOf<PromptWithToolsAuto, true>>(
{} as PromptOptsWithToolsAutoStreamResponse
);
// stream: boolean
type CompositeResponse = PromptCompositeResponse<PromptOptsWithToolsAuto & { stream: boolean }>;
expectAssignable<
Promise<PromptResponse<PromptOptsWithToolsAuto>> | PromptStreamResponse<PromptOptsWithToolsAuto>
>({} as CompositeResponse);
/**
* Test PromptAPI runtime inference
*/
// Default stream (false), with tools
const resWithTools = mockApi({
connectorId: 'c1',
prompt: promptWithToolsAuto,
input: minimalInput,
});
expectType<Promise<PromptResponse<PromptOptsWithToolsAuto>>>(resWithTools);
resWithTools.then((r) => {
if (r.toolCalls && r.toolCalls.length > 0) {
expectType<'get_weather' | 'get_stock_price'>(r.toolCalls[0].function.name);
expectAssignable<ToolResponseOf<PromptTools[keyof PromptTools]>>(
r.toolCalls[0].function.arguments
);
}
});
// stream: true, with tools
const resStreamWithTools = mockApi({
connectorId: 'c1',
prompt: promptWithToolsAuto,
input: minimalInput,
stream: true,
});
expectType<PromptStreamResponse<PromptOptsWithToolsAuto>>(resStreamWithTools);
// toolChoice: none
const resToolChoiceNone = mockApi({
connectorId: 'c1',
prompt: promptWithToolsNone,
input: minimalInput,
});
resToolChoiceNone.then((r) => {
expectType<[]>(r.toolCalls);
});
// toolChoice: specific function
const resToolChoiceSpecific = mockApi({
connectorId: 'c1',
prompt: promptWithGetWeatherToolChoice,
input: minimalInput,
});
expectType<PromptResponseOf<typeof promptWithGetWeatherToolChoice>>(resToolChoiceSpecific);
resToolChoiceSpecific.then((r) => {
if (r.toolCalls && r.toolCalls.length > 0) {
// With a specific tool choice, only that tool should be possible.
// The ToolOptionsOfPrompt<typeof promptWithToolsSpecific> should correctly narrow this.
expectType<'get_weather'>(r.toolCalls[0].function.name);
expectType<ToolResponseOf<PromptTools['get_weather']>>(r.toolCalls[0].function.arguments);
}
});
// New tool definition for API call options
const getGeolocationToolDefinition = {
schema: {
type: 'object',
properties: { address: { type: 'string' } },
required: ['address'],
},
description: 'Get geolocation for an address',
} satisfies ToolDefinition;
const apiCallTools = {
get_geolocation: getGeolocationToolDefinition,
};
// scenario: no tools in prompt, tools in API call
const resNoPromptToolsWithApiTools = mockApi({
connectorId: 'c1',
prompt: promptNoTools,
input: minimalInput,
tools: apiCallTools, // Tools provided directly in API call options
toolChoice: ToolChoiceType.auto,
});
// should just return the tools from the API call
expectType<
Promise<{
content: string;
toolCalls: ToolCallsOf<{ tools: typeof apiCallTools }>['toolCalls'];
}>
>(resNoPromptToolsWithApiTools);
resNoPromptToolsWithApiTools.then((r) => {
expectType<{
toolCallId: string;
function: {
name: 'get_geolocation';
arguments: {
address?: string;
};
};
}>(r.toolCalls[0]);
});
// scenario: prompt tools + api tools
const resPromptToolsWithApiTools = mockApi({
connectorId: 'c1',
prompt: promptWithToolsAuto,
input: minimalInput,
tools: apiCallTools,
toolChoice: ToolChoiceType.auto,
});
expectType<
Promise<PromptResponse<PromptOptions<PromptWithToolsAuto> & { tools: typeof apiCallTools }>>
>(resPromptToolsWithApiTools);
// merges tools from prompt with api
resPromptToolsWithApiTools.then((r) => {
if (r.toolCalls && r.toolCalls.length > 0) {
expectType<'get_geolocation' | 'get_weather' | 'get_stock_price'>(r.toolCalls[0].function.name);
}
});
// scenario: prompt tools + api tools + api tool choice override
const resPromptSpecificToolApiChoiceOverride = mockApi({
connectorId: 'c1',
prompt: promptWithGetWeatherToolChoice,
input: minimalInput,
tools: promptTools,
toolChoice: { type: 'function', function: 'get_stock_price' as const },
});
expectType<
Promise<
PromptResponse<
PromptOptions<typeof promptWithGetWeatherToolChoice> & {
tools: PromptTools;
toolChoice: { function: 'get_stock_price' };
}
>
>
>(resPromptSpecificToolApiChoiceOverride);
resPromptSpecificToolApiChoiceOverride.then((r) => {
if (r.toolCalls && r.toolCalls.length > 0) {
expectType<'get_stock_price'>(r.toolCalls[0].function.name);
}
});

View file

@ -64,6 +64,6 @@ export type ToolOptionsOfPrompt<TPrompt extends Prompt> = TPrompt['versions'] ex
infer TPromptVersion
>
? TPromptVersion extends PromptVersion
? Pick<TPromptVersion, 'tools'>
? Pick<TPromptVersion, 'tools' | 'toolChoice'>
: never
: {};
: never;

View file

@ -34,14 +34,13 @@ import {
InferenceConnector,
ChatCompleteAPI,
ChatCompleteOptions,
ChatCompleteCompositeResponse,
FunctionCallingMode,
ToolOptions,
isChatCompletionChunkEvent,
isChatCompletionTokenCountEvent,
isToolValidationError,
getConnectorDefaultModel,
getConnectorProvider,
ChatCompleteResponse,
ConnectorTelemetryMetadata,
} from '@kbn/inference-common';
import type { ToolChoice } from './types';
@ -191,15 +190,9 @@ export class InferenceChatModel extends BaseChatModel<InferenceChatModelCallOpti
};
}
async completionWithRetry(
request: ChatCompleteOptions<ToolOptions, false>
): Promise<ChatCompleteCompositeResponse<ToolOptions, false>>;
async completionWithRetry(
request: ChatCompleteOptions<ToolOptions, true>
): Promise<ChatCompleteCompositeResponse<ToolOptions, true>>;
async completionWithRetry(
request: ChatCompleteOptions<ToolOptions, boolean>
): Promise<ChatCompleteCompositeResponse<ToolOptions, boolean>> {
completionWithRetry = <TStream extends boolean | undefined = false>(
request: ChatCompleteOptions & { stream?: TStream }
) => {
return this.caller.call(async () => {
try {
return await this.chatComplete(request);
@ -207,7 +200,7 @@ export class InferenceChatModel extends BaseChatModel<InferenceChatModelCallOpti
throw wrapInferenceError(e);
}
});
}
};
async _generate(
baseMessages: BaseMessage[],
@ -216,7 +209,8 @@ export class InferenceChatModel extends BaseChatModel<InferenceChatModelCallOpti
): Promise<ChatResult> {
const { system, messages } = messagesToInference(baseMessages);
let response: Awaited<ChatCompleteCompositeResponse<ToolOptions, false>>;
let response: ChatCompleteResponse;
try {
response = await this.completionWithRetry({
...this.invocationParams(options),
@ -269,7 +263,7 @@ export class InferenceChatModel extends BaseChatModel<InferenceChatModelCallOpti
system,
messages,
stream: true as const,
} as ChatCompleteOptions<ToolOptions, true>);
});
const responseIterator = toAsyncIterator(response$);
for await (const event of responseIterator) {

View file

@ -44,6 +44,26 @@ export const AttackDiscoveryAlert = z.object({
* The (human readable) name of the connector that generated the attack discovery
*/
connectorName: z.string(),
/**
* The optional time the attack discovery alert was created
*/
alertStart: z.string().optional(),
/**
* The optional time the attack discovery alert was last updated
*/
alertUpdatedAt: z.string().optional(),
/**
* The optional id of the user who last updated the attack discovery alert
*/
alertUpdatedByUserId: z.string().optional(),
/**
* The optional username of the user who updated the attack discovery alert
*/
alertUpdatedByUserName: z.string().optional(),
/**
* The optional time the attack discovery alert workflow status was last updated
*/
alertWorkflowStatusUpdatedAt: z.string().optional(),
/**
* Details of the attack with bulleted markdown that always uses special syntax for field names and values from the source data.
*/

View file

@ -37,6 +37,21 @@ components:
connectorName:
description: The (human readable) name of the connector that generated the attack discovery
type: string
alertStart:
description: The optional time the attack discovery alert was created
type: string
alertUpdatedAt:
description: The optional time the attack discovery alert was last updated
type: string
alertUpdatedByUserId:
description: The optional id of the user who last updated the attack discovery alert
type: string
alertUpdatedByUserName:
description: The optional username of the user who updated the attack discovery alert
type: string
alertWorkflowStatusUpdatedAt:
description: The optional time the attack discovery alert workflow status was last updated
type: string
detailsMarkdown:
description: Details of the attack with bulleted markdown that always uses special syntax for field names and values from the source data.
type: string

View file

@ -10,7 +10,6 @@ import {
BoundChatCompleteAPI,
ChatCompleteResponse,
ChatCompletionEvent,
ToolOptions,
UnboundChatCompleteOptions,
} from '@kbn/inference-common';
import { defer, from } from 'rxjs';
@ -21,9 +20,7 @@ import { combineSignal } from './combine_signal';
export function createChatComplete(options: InferenceCliClientOptions): BoundChatCompleteAPI;
export function createChatComplete({ connector, kibanaClient, signal }: InferenceCliClientOptions) {
return <TToolOptions extends ToolOptions, TStream extends boolean = false>(
options: UnboundChatCompleteOptions<TToolOptions, TStream>
) => {
return (options: UnboundChatCompleteOptions) => {
const {
messages,
abortSignal,
@ -67,16 +64,13 @@ export function createChatComplete({ connector, kibanaClient, signal }: Inferenc
})
.then((response) => ({ response }))
);
}).pipe(httpResponseIntoObservable<ChatCompletionEvent<TToolOptions>>());
}).pipe(httpResponseIntoObservable<ChatCompletionEvent>());
}
return kibanaClient.fetch<ChatCompleteResponse<TToolOptions>>(
`/internal/inference/chat_complete`,
{
method: 'POST',
body,
signal: combineSignal(signal, abortSignal),
}
);
return kibanaClient.fetch<ChatCompleteResponse>(`/internal/inference/chat_complete`, {
method: 'POST',
body,
signal: combineSignal(signal, abortSignal),
});
};
}

View file

@ -15,7 +15,6 @@ import {
ToolChoice,
ToolDefinition,
ToolMessage,
ToolOptions,
UserMessage,
isChatCompletionMessageEvent,
isChatCompletionTokenCountEvent,
@ -146,15 +145,15 @@ function mapAssistantResponse({
* @param options
* @param cb
*/
export function withChatCompleteSpan<T extends ChatCompleteCompositeResponse<ToolOptions, boolean>>(
export function withChatCompleteSpan<T extends ChatCompleteCompositeResponse>(
options: InferenceGenerationOptions,
cb: (span?: Span) => T
): T;
export function withChatCompleteSpan(
options: InferenceGenerationOptions,
cb: (span?: Span) => ChatCompleteCompositeResponse<ToolOptions, boolean>
): ChatCompleteCompositeResponse<ToolOptions, boolean> {
cb: (span?: Span) => ChatCompleteCompositeResponse
): ChatCompleteCompositeResponse {
const { system, messages, model, toolChoice, tools, ...attributes } = options;
const next = withInferenceSpan(

View file

@ -41,6 +41,7 @@ test.describe(
page,
pageObjects,
perfTracker,
config,
}) => {
perfTracker.captureBundleResponses(cdp); // Start tracking
@ -75,6 +76,7 @@ test.describe(
'lens',
'maps',
'unifiedHistogram',
...(config.projectType === 'security' ? ['securitySolution'] : []),
'unifiedSearch',
]);
// Validate individual plugin bundle sizes

View file

@ -0,0 +1,492 @@
/*
* 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 { BehaviorSubject } from 'rxjs';
import { Readable } from 'stream';
import supertest from 'supertest';
jest.mock('../../../../lib/content_stream', () => ({
getContentStream: jest.fn(),
}));
import { setupServer } from '@kbn/core-test-helpers-test-utils';
import {
ElasticsearchClientMock,
coreMock,
elasticsearchServiceMock,
loggingSystemMock,
} from '@kbn/core/server/mocks';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { INTERNAL_ROUTES } from '@kbn/reporting-common';
import { createMockConfigSchema } from '@kbn/reporting-mocks-server';
import { ExportType } from '@kbn/reporting-server';
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
import { IUsageCounter } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counter';
import { ReportingCore } from '../../../..';
import { ReportingInternalSetup, ReportingInternalStart } from '../../../../core';
import { ContentStream, getContentStream } from '../../../../lib';
import { reportingMock } from '../../../../mocks';
import {
createMockPluginSetup,
createMockPluginStart,
createMockReportingCore,
} from '../../../../test_helpers';
import { ReportingRequestHandlerContext, ScheduledReportType } from '../../../../types';
import { EventTracker } from '../../../../usage';
import { registerScheduledRoutesInternal } from '../scheduled';
import {
KibanaRequest,
SavedObject,
SavedObjectsClientContract,
SavedObjectsFindResponse,
} from '@kbn/core/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server';
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
const fakeRawRequest = {
headers: {
authorization: `ApiKey skdjtq4u543yt3rhewrh`,
},
path: '/',
} as unknown as KibanaRequest;
const payload =
'{"browserTimezone":"America/New_York","layout":{"dimensions":{"height":2220,"width":1364},"id":"preserve_layout"},"objectType":"dashboard","title":"[Logs] Web Traffic","version":"9.1.0","locatorParams":[{"id":"DASHBOARD_APP_LOCATOR","params":{"dashboardId":"edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b","preserveSavedFilters":true,"timeRange":{"from":"now-7d/d","to":"now"},"useHash":false,"viewMode":"view"}}],"isDeprecated":false}';
const jsonPayload = JSON.parse(payload);
const savedObjects: Array<SavedObject<ScheduledReportType>> = [
{
type: 'scheduled_report',
id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca',
namespaces: ['a-space'],
attributes: {
createdAt: '2025-05-06T21:10:17.137Z',
createdBy: 'elastic',
enabled: true,
jobType: 'printable_pdf_v2',
meta: {
isDeprecated: false,
layout: 'preserve_layout',
objectType: 'dashboard',
},
migrationVersion: '9.1.0',
title: '[Logs] Web Traffic',
payload,
schedule: {
rrule: {
freq: 3,
interval: 3,
byhour: [12],
byminute: [0],
tzid: 'UTC',
},
},
},
references: [],
managed: false,
updated_at: '2025-05-06T21:10:17.137Z',
created_at: '2025-05-06T21:10:17.137Z',
version: 'WzEsMV0=',
coreMigrationVersion: '8.8.0',
typeMigrationVersion: '10.1.0',
},
{
type: 'scheduled_report',
id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4',
namespaces: ['a-space'],
attributes: {
createdAt: '2025-05-06T21:12:06.584Z',
createdBy: 'Tom Riddle',
enabled: true,
jobType: 'PNGV2',
meta: {
isDeprecated: false,
layout: 'preserve_layout',
objectType: 'dashboard',
},
migrationVersion: '9.1.0',
notification: {
email: {
to: ['user@elastic.co'],
},
},
title: 'Another cool dashboard',
payload:
'{"browserTimezone":"America/New_York","layout":{"dimensions":{"height":2220,"width":1364},"id":"preserve_layout"},"objectType":"dashboard","title":"[Logs] Web Traffic","version":"9.1.0","locatorParams":[{"id":"DASHBOARD_APP_LOCATOR","params":{"dashboardId":"edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b","preserveSavedFilters":true,"timeRange":{"from":"now-7d/d","to":"now"},"useHash":false,"viewMode":"view"}}],"isDeprecated":false}',
schedule: {
rrule: {
freq: 1,
interval: 3,
tzid: 'UTC',
},
},
},
references: [],
managed: false,
updated_at: '2025-05-06T21:12:06.584Z',
created_at: '2025-05-06T21:12:06.584Z',
version: 'WzIsMV0=',
coreMigrationVersion: '8.8.0',
typeMigrationVersion: '10.1.0',
},
];
const soResponse: SavedObjectsFindResponse<ScheduledReportType> = {
page: 1,
per_page: 10,
total: 2,
saved_objects: savedObjects.map((so) => ({ ...so, score: 0 })),
};
const auditLogger = auditLoggerMock.create();
describe(`Reporting Schedule Management Routes: Internal`, () => {
const reportingSymbol = Symbol('reporting');
let server: SetupServerReturn['server'];
let eventTracker: EventTracker;
let usageCounter: IUsageCounter;
let httpSetup: SetupServerReturn['httpSetup'];
let exportTypesRegistry: ExportTypesRegistry;
let reportingCore: ReportingCore;
let mockSetupDeps: ReportingInternalSetup;
let mockStartDeps: ReportingInternalStart;
let mockEsClient: ElasticsearchClientMock;
let stream: jest.Mocked<ContentStream>;
let soClient: SavedObjectsClientContract;
let client: ReturnType<typeof elasticsearchServiceMock.createElasticsearchClient>;
let taskManager: TaskManagerStartContract;
const mockLogger = loggingSystemMock.createLogger();
const mockJobTypeUnencoded = 'unencodedJobType';
const mockJobTypeBase64Encoded = 'base64EncodedJobType';
const coreSetupMock = coreMock.createSetup();
const mockConfigSchema = createMockConfigSchema();
beforeEach(async () => {
({ server, httpSetup } = await setupServer(reportingSymbol));
httpSetup.registerRouteHandlerContext<ReportingRequestHandlerContext, 'reporting'>(
reportingSymbol,
'reporting',
() => reportingMock.createStart()
);
mockSetupDeps = createMockPluginSetup({
security: {
license: { isEnabled: () => true },
},
router: httpSetup.createRouter(''),
});
mockStartDeps = await createMockPluginStart(
{
licensing: {
...licensingMock.createStart(),
license$: new BehaviorSubject({ isActive: true, isAvailable: true, type: 'gold' }),
},
securityService: {
authc: {
getCurrentUser: () => ({ id: '123', roles: ['superuser'], username: 'Tom Riddle' }),
},
audit: {
asScoped: () => auditLogger,
},
},
},
mockConfigSchema
);
reportingCore = await createMockReportingCore(mockConfigSchema, mockSetupDeps, mockStartDeps);
soClient = await reportingCore.getScopedSoClient(fakeRawRequest);
soClient.find = jest.fn().mockImplementation(async () => {
return soResponse;
});
soClient.bulkGet = jest
.fn()
.mockImplementation(async () => ({ saved_objects: [savedObjects[1]] }));
soClient.bulkUpdate = jest.fn().mockImplementation(async () => ({
saved_objects: [savedObjects[1]].map((so) => ({
id: so.id,
type: so.type,
attributes: { enabled: false },
})),
}));
taskManager = await reportingCore.getTaskManager();
taskManager.bulkDisable = jest.fn().mockImplementation(async () => ({
tasks: [savedObjects[1]].map((so) => ({ id: so.id })),
errors: [],
}));
client = (await reportingCore.getEsClient()).asInternalUser as typeof client;
client.search.mockResponse({
took: 1,
timed_out: false,
_shards: { total: 1, successful: 1, skipped: 0, failed: 0 },
hits: { total: { value: 0, relation: 'eq' }, max_score: null, hits: [] },
});
usageCounter = {
domainId: 'abc123',
incrementCounter: jest.fn(),
};
jest.spyOn(reportingCore, 'getUsageCounter').mockReturnValue(usageCounter);
eventTracker = new EventTracker(coreSetupMock.analytics, 'jobId', 'exportTypeId', 'appId');
jest.spyOn(reportingCore, 'getEventTracker').mockReturnValue(eventTracker);
exportTypesRegistry = new ExportTypesRegistry();
exportTypesRegistry.register({
id: 'unencoded',
jobType: mockJobTypeUnencoded,
jobContentExtension: 'csv',
validLicenses: ['basic', 'gold'],
} as ExportType);
exportTypesRegistry.register({
id: 'base64Encoded',
jobType: mockJobTypeBase64Encoded,
jobContentEncoding: 'base64',
jobContentExtension: 'pdf',
validLicenses: ['basic', 'gold'],
} as ExportType);
reportingCore.getExportTypesRegistry = () => exportTypesRegistry;
mockEsClient = (await reportingCore.getEsClient()).asInternalUser as typeof mockEsClient;
stream = new Readable({
read() {
this.push('test');
this.push(null);
},
}) as typeof stream;
stream.end = jest.fn().mockImplementation((_name, _encoding, callback) => {
callback();
});
(getContentStream as jest.MockedFunction<typeof getContentStream>).mockResolvedValue(stream);
});
afterEach(async () => {
await server.stop();
});
describe('list scheduled reports', () => {
it('correct lists scheduled reports', async () => {
registerScheduledRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.get(`${INTERNAL_ROUTES.SCHEDULED.LIST}`)
.expect(200)
.then(({ body }) =>
expect(body).toEqual({
page: 1,
per_page: 10,
total: 2,
data: [
{
id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca',
created_at: '2025-05-06T21:10:17.137Z',
created_by: 'elastic',
enabled: true,
jobtype: 'printable_pdf_v2',
next_run: expect.any(String),
payload: jsonPayload,
schedule: {
rrule: {
freq: 3,
interval: 3,
byhour: [12],
byminute: [0],
tzid: 'UTC',
},
},
space_id: 'a-space',
title: '[Logs] Web Traffic',
},
{
id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4',
created_at: '2025-05-06T21:12:06.584Z',
created_by: 'Tom Riddle',
enabled: true,
jobtype: 'PNGV2',
next_run: expect.any(String),
notification: {
email: {
to: ['user@elastic.co'],
},
},
payload: jsonPayload,
space_id: 'a-space',
title: 'Another cool dashboard',
schedule: {
rrule: {
freq: 1,
interval: 3,
tzid: 'UTC',
},
},
},
],
})
);
});
it('fails on unauthenticated users', async () => {
mockStartDeps = await createMockPluginStart(
{
licensing: {
...licensingMock.createStart(),
license$: new BehaviorSubject({ isActive: true, isAvailable: true, type: 'gold' }),
},
securityService: {
authc: { getCurrentUser: () => undefined },
audit: {
asScoped: () => auditLogger,
},
}, // security comes from core here
},
mockConfigSchema
);
reportingCore = await createMockReportingCore(mockConfigSchema, mockSetupDeps, mockStartDeps);
registerScheduledRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.get(`${INTERNAL_ROUTES.SCHEDULED.LIST}`)
.expect(401)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot(`"Sorry, you aren't authenticated"`)
);
});
it('fails on insufficient license', async () => {
mockStartDeps = await createMockPluginStart(
{
licensing: {
...licensingMock.createStart(),
license$: new BehaviorSubject({ isActive: true, isAvailable: true, type: 'basic' }),
},
securityService: {
authc: {
getCurrentUser: () => ({ id: '123', roles: ['superuser'], username: 'Tom Riddle' }),
},
audit: {
asScoped: () => auditLogger,
},
},
},
mockConfigSchema
);
reportingCore = await createMockReportingCore(mockConfigSchema, mockSetupDeps, mockStartDeps);
registerScheduledRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.get(`${INTERNAL_ROUTES.SCHEDULED.LIST}`)
.expect(403)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot(
`"Your basic license does not support Scheduled reports. Please upgrade your license."`
)
);
});
});
describe('disable scheduled reports', () => {
it('correct disables scheduled reports', async () => {
registerScheduledRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.patch(`${INTERNAL_ROUTES.SCHEDULED.BULK_DISABLE}`)
.send({
ids: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'],
})
.expect(200)
.then(({ body }) =>
expect(body).toEqual({
total: 1,
scheduled_report_ids: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'],
errors: [],
})
);
});
it('fails on unauthenticated users', async () => {
mockStartDeps = await createMockPluginStart(
{
licensing: {
...licensingMock.createStart(),
license$: new BehaviorSubject({ isActive: true, isAvailable: true, type: 'gold' }),
},
securityService: {
authc: { getCurrentUser: () => undefined },
audit: {
asScoped: () => auditLogger,
},
}, // security comes from core here
},
mockConfigSchema
);
reportingCore = await createMockReportingCore(mockConfigSchema, mockSetupDeps, mockStartDeps);
registerScheduledRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.patch(`${INTERNAL_ROUTES.SCHEDULED.BULK_DISABLE}`)
.send({
ids: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'],
})
.expect(401)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot(`"Sorry, you aren't authenticated"`)
);
});
it('fails on insufficient license', async () => {
mockStartDeps = await createMockPluginStart(
{
licensing: {
...licensingMock.createStart(),
license$: new BehaviorSubject({ isActive: true, isAvailable: true, type: 'basic' }),
},
securityService: {
authc: {
getCurrentUser: () => ({ id: '123', roles: ['superuser'], username: 'Tom Riddle' }),
},
audit: {
asScoped: () => auditLogger,
},
},
},
mockConfigSchema
);
reportingCore = await createMockReportingCore(mockConfigSchema, mockSetupDeps, mockStartDeps);
registerScheduledRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.patch(`${INTERNAL_ROUTES.SCHEDULED.BULK_DISABLE}`)
.send({
ids: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'],
})
.expect(403)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot(
`"Your basic license does not support Scheduled reports. Please upgrade your license."`
)
);
});
});
});

View file

@ -67,6 +67,14 @@ export function registerScheduledRoutesInternal(reporting: ReportingCore, logger
return handleUnavailable(res);
}
// check license
const licenseInfo = await reporting.getLicenseInfo();
const licenseResults = licenseInfo.scheduledReports;
if (!licenseResults.enableLinks) {
return res.forbidden({ body: licenseResults.message });
}
const {
page: queryPage = '1',
size: querySize = `${DEFAULT_SCHEDULED_REPORT_LIST_SIZE}`,
@ -120,6 +128,14 @@ export function registerScheduledRoutesInternal(reporting: ReportingCore, logger
return handleUnavailable(res);
}
// check license
const licenseInfo = await reporting.getLicenseInfo();
const licenseResults = licenseInfo.scheduledReports;
if (!licenseResults.enableLinks) {
return res.forbidden({ body: licenseResults.message });
}
const { ids } = req.body;
const results = await scheduledQuery.bulkDisable(logger, req, res, ids, user);

View file

@ -0,0 +1,41 @@
title: Connector request properties for an OpenAI connector with Other provider
description: >
Defines properties for connectors when type is `.gen-ai` and the API provider is `Other` (OpenAI-compatible service), including optional PKI authentication.
type: object
required:
- apiProvider
- apiUrl
- defaultModel
properties:
apiProvider:
type: string
description: The OpenAI API provider.
enum: ['Other']
apiUrl:
type: string
description: The OpenAI-compatible API endpoint.
defaultModel:
type: string
description: The default model to use for requests.
certificateData:
type: string
description: PEM-encoded certificate content.
minLength: 1
privateKeyData:
type: string
description: PEM-encoded private key content.
minLength: 1
caData:
type: string
description: PEM-encoded CA certificate content.
minLength: 1
verificationMode:
type: string
description: SSL verification mode for PKI authentication.
enum: ['full', 'certificate', 'none']
default: 'full'
headers:
type: object
description: Custom headers to include in requests.
additionalProperties:
type: string

View file

@ -1,7 +1,26 @@
title: Connector secrets properties for an OpenAI connector
description: Defines secrets for connectors when type is `.gen-ai`.
# Defines secrets for connectors when type is `.gen-ai`, including support for PKI authentication for 'Other' providers.
description: |
Defines secrets for connectors when type is `.gen-ai`. Supports both API key authentication (OpenAI, Azure OpenAI, and `Other`) and PKI authentication (`Other` provider only). PKI fields must be base64-encoded PEM content.
type: object
properties:
apiKey:
type: string
description: The OpenAI API key.
apiKey:
type: string
description: |
The API key for authentication. For OpenAI and Azure OpenAI providers, it is required. For the `Other` provider, it is required if you do not use PKI authentication. With PKI, you can also optionally include an API key if the OpenAI-compatible service supports or requires one.
certificateData:
type: string
description: |
Base64-encoded PEM certificate content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
privateKeyData:
type: string
description: |
Base64-encoded PEM private key content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
caData:
type: string
description: |
Base64-encoded PEM CA certificate content for PKI authentication (Other provider only). Optional.
minLength: 1
required: []

View file

@ -24,6 +24,7 @@ export type {
ActionType,
InMemoryConnector,
ActionsApiRequestHandlerContext,
SSLSettings,
} from './types';
export type { ConnectorWithExtraFindData as FindActionResult } from './application/connector/types';

View file

@ -2763,7 +2763,7 @@ Object {
},
Object {
"args": Object {
"limit": 10,
"limit": 20,
},
"name": "max",
},
@ -2870,7 +2870,7 @@ Object {
},
Object {
"args": Object {
"limit": 10,
"limit": 20,
},
"name": "max",
},

View file

@ -71,7 +71,8 @@ export function getCustomAgents(
// This is where the global rejectUnauthorized is overridden by a custom host
const customHostNodeSSLOptions = getNodeSSLOptions(
logger,
sslSettingsFromConfig.verificationMode
sslSettingsFromConfig.verificationMode,
sslOverrides
);
if (customHostNodeSSLOptions.rejectUnauthorized !== undefined) {
agentOptions.rejectUnauthorized = customHostNodeSSLOptions.rejectUnauthorized;
@ -119,7 +120,8 @@ export function getCustomAgents(
const proxyNodeSSLOptions = getNodeSSLOptions(
logger,
proxySettings.proxySSLSettings.verificationMode
proxySettings.proxySSLSettings.verificationMode,
sslOverrides
);
// At this point, we are going to use a proxy, so we need new agents.
// We will though, copy over the calculated ssl options from above, into

View file

@ -16,6 +16,7 @@ import type {
ActionTypeParams,
RenderParameterTemplates,
Services,
SSLSettings,
ValidatorType as ValidationSchema,
} from '../types';
import type { SubFeature } from '../../common';
@ -41,6 +42,7 @@ export type SubActionRequestParams<R> = {
url: string;
responseSchema: Type<R>;
method?: Method;
sslOverrides?: SSLSettings;
} & AxiosRequestConfig;
export type IService<Config, Secrets> = new (

View file

@ -324,6 +324,23 @@ describe('mappingFromFieldMap', () => {
type: 'date_range',
format: 'epoch_millis||strict_date_optional_time',
},
updated_at: {
type: 'date',
},
updated_by: {
properties: {
user: {
properties: {
id: {
type: 'keyword',
},
name: {
type: 'keyword',
},
},
},
},
},
url: {
ignore_above: 2048,
index: false,

View file

@ -1477,6 +1477,21 @@ Object {
"required": false,
"type": "date_range",
},
"kibana.alert.updated_at": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.updated_by.user.id": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.updated_by.user.name": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.url": Object {
"array": false,
"ignore_above": 2048,
@ -2615,6 +2630,21 @@ Object {
"required": false,
"type": "date_range",
},
"kibana.alert.updated_at": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.updated_by.user.id": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.updated_by.user.name": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.url": Object {
"array": false,
"ignore_above": 2048,
@ -3753,6 +3783,21 @@ Object {
"required": false,
"type": "date_range",
},
"kibana.alert.updated_at": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.updated_by.user.id": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.updated_by.user.name": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.url": Object {
"array": false,
"ignore_above": 2048,
@ -4891,6 +4936,21 @@ Object {
"required": false,
"type": "date_range",
},
"kibana.alert.updated_at": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.updated_by.user.id": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.updated_by.user.name": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.url": Object {
"array": false,
"ignore_above": 2048,
@ -6029,6 +6089,21 @@ Object {
"required": false,
"type": "date_range",
},
"kibana.alert.updated_at": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.updated_by.user.id": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.updated_by.user.name": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.url": Object {
"array": false,
"ignore_above": 2048,
@ -7173,6 +7248,21 @@ Object {
"required": false,
"type": "date_range",
},
"kibana.alert.updated_at": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.updated_by.user.id": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.updated_by.user.name": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.url": Object {
"array": false,
"ignore_above": 2048,
@ -8311,6 +8401,21 @@ Object {
"required": false,
"type": "date_range",
},
"kibana.alert.updated_at": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.updated_by.user.id": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.updated_by.user.name": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.url": Object {
"array": false,
"ignore_above": 2048,
@ -9449,6 +9554,21 @@ Object {
"required": false,
"type": "date_range",
},
"kibana.alert.updated_at": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.updated_by.user.id": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.updated_by.user.name": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.url": Object {
"array": false,
"ignore_above": 2048,
@ -9979,6 +10099,253 @@ Object {
}
`;
exports[`Alert as data fields checks detect AAD fields changes for: streams.rules.esql 1`] = `
Object {
"dynamic": false,
"fieldMap": Object {
"@timestamp": Object {
"array": false,
"required": true,
"type": "date",
},
"event.action": Object {
"array": false,
"ignore_above": 1024,
"required": false,
"type": "keyword",
},
"event.kind": Object {
"array": false,
"ignore_above": 1024,
"required": false,
"type": "keyword",
},
"event.original": Object {
"array": false,
"ignore_above": 1024,
"required": false,
"type": "keyword",
},
"kibana.alert.action_group": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.case_ids": Object {
"array": true,
"required": false,
"type": "keyword",
},
"kibana.alert.consecutive_matches": Object {
"array": false,
"required": false,
"type": "long",
},
"kibana.alert.duration.us": Object {
"array": false,
"required": false,
"type": "long",
},
"kibana.alert.end": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.flapping": Object {
"array": false,
"required": false,
"type": "boolean",
},
"kibana.alert.flapping_history": Object {
"array": true,
"required": false,
"type": "boolean",
},
"kibana.alert.instance.id": Object {
"array": false,
"required": true,
"type": "keyword",
},
"kibana.alert.intended_timestamp": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.last_detected": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.maintenance_window_ids": Object {
"array": true,
"required": false,
"type": "keyword",
},
"kibana.alert.pending_recovered_count": Object {
"array": false,
"required": false,
"type": "long",
},
"kibana.alert.previous_action_group": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.reason": Object {
"array": false,
"multi_fields": Array [
Object {
"flat_name": "kibana.alert.reason.text",
"name": "text",
"type": "match_only_text",
},
],
"required": false,
"type": "keyword",
},
"kibana.alert.rule.category": Object {
"array": false,
"required": true,
"type": "keyword",
},
"kibana.alert.rule.consumer": Object {
"array": false,
"required": true,
"type": "keyword",
},
"kibana.alert.rule.execution.timestamp": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.rule.execution.type": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.rule.execution.uuid": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.rule.name": Object {
"array": false,
"required": true,
"type": "keyword",
},
"kibana.alert.rule.parameters": Object {
"array": false,
"ignore_above": 4096,
"required": false,
"type": "flattened",
},
"kibana.alert.rule.producer": Object {
"array": false,
"required": true,
"type": "keyword",
},
"kibana.alert.rule.revision": Object {
"array": false,
"required": true,
"type": "long",
},
"kibana.alert.rule.rule_type_id": Object {
"array": false,
"required": true,
"type": "keyword",
},
"kibana.alert.rule.tags": Object {
"array": true,
"required": false,
"type": "keyword",
},
"kibana.alert.rule.uuid": Object {
"array": false,
"required": true,
"type": "keyword",
},
"kibana.alert.severity_improving": Object {
"array": false,
"required": false,
"type": "boolean",
},
"kibana.alert.start": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.status": Object {
"array": false,
"required": true,
"type": "keyword",
},
"kibana.alert.time_range": Object {
"array": false,
"format": "epoch_millis||strict_date_optional_time",
"required": false,
"type": "date_range",
},
"kibana.alert.updated_at": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.updated_by.user.id": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.updated_by.user.name": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.url": Object {
"array": false,
"ignore_above": 2048,
"index": false,
"required": false,
"type": "keyword",
},
"kibana.alert.uuid": Object {
"array": false,
"required": true,
"type": "keyword",
},
"kibana.alert.workflow_assignee_ids": Object {
"array": true,
"required": false,
"type": "keyword",
},
"kibana.alert.workflow_status": Object {
"array": false,
"required": false,
"type": "keyword",
},
"kibana.alert.workflow_tags": Object {
"array": true,
"required": false,
"type": "keyword",
},
"kibana.space_ids": Object {
"array": true,
"required": true,
"type": "keyword",
},
"kibana.version": Object {
"array": false,
"required": false,
"type": "version",
},
"tags": Object {
"array": true,
"required": false,
"type": "keyword",
},
},
}
`;
exports[`Alert as data fields checks detect AAD fields changes for: transform_health 1`] = `
Object {
"fieldMap": Object {

View file

@ -27,6 +27,7 @@ const ruleTypes: string[] = [
'xpack.ml.anomaly_detection_alert',
'xpack.ml.anomaly_detection_jobs_health',
'slo.rules.burnRate',
'streams.rules.esql',
'observability.rules.custom_threshold',
'xpack.uptime.alerts.monitorStatus',
'xpack.uptime.alerts.tlsCertificate',

View file

@ -27,6 +27,12 @@ export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure' as const;
export const CASE_RULES_SAVED_OBJECT = 'cases-rules' as const;
export const CASE_ID_INCREMENTER_SAVED_OBJECT = 'cases-incrementing-id' as const;
/**
* UI settings
*/
export const CASES_UI_SETTING_ID_DISPLAY_INCREMENTAL_ID = 'cases:incrementalIdDisplay:enabled';
/**
* If more values are added here please also add them here: x-pack/test/cases_api_integration/common/plugins
*/

View file

@ -246,6 +246,7 @@ export const BulkCreateCasesResponseRt = rt.strict({
export const CasesFindRequestSearchFieldsRt = rt.keyof({
description: null,
title: null,
incremental_id: null,
});
export const CasesFindRequestSortFieldsRt = rt.keyof({

View file

@ -72,6 +72,9 @@ export interface CasesUiConfigType {
stack: {
enabled: boolean;
};
incrementalId: {
enabled: boolean;
};
}
export const UserActionTypeAll = 'all' as const;
@ -342,3 +345,7 @@ export interface CasesCapabilities {
[CASES_REOPEN_CAPABILITY]: boolean;
[ASSIGN_CASE_CAPABILITY]: boolean;
}
export interface CasesSettings {
displayIncrementalCaseId: boolean;
}

View file

@ -23,7 +23,7 @@ import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { CoreStart } from '@kbn/core/public';
import type { BaseFilesClient } from '@kbn/shared-ux-file-types';
import type { CasesFeatures, CasesPermissions } from '../../../common/ui/types';
import type { CasesFeatures, CasesPermissions, CasesSettings } from '../../../common/ui/types';
import type { ReleasePhase } from '../../components/types';
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import type { CasesContextProps } from '../../components/cases_context';
@ -47,6 +47,7 @@ interface TestProviderProps {
queryClient?: QueryClient;
coreStart?: CoreStart;
filesClient?: BaseFilesClient;
settings?: CasesSettings;
}
window.scrollTo = jest.fn();
@ -92,6 +93,7 @@ const TestProvidersComponent: React.FC<TestProviderProps> = ({
services,
queryClient,
filesClient,
settings,
}) => {
const finalCoreStart = useMemo(() => coreStart ?? coreMock.createStart(), [coreStart]);
const finalServices = useMemo(
@ -138,6 +140,9 @@ const TestProvidersComponent: React.FC<TestProviderProps> = ({
permissions: permissions ?? defaultPermissions,
releasePhase: releasePhase ?? 'ga',
getFilesClient: getFilesClientFinal,
settings: settings ?? {
displayIncrementalCaseId: false,
},
}),
[
defaultExternalReferenceAttachmentTypeRegistry,
@ -150,6 +155,7 @@ const TestProvidersComponent: React.FC<TestProviderProps> = ({
permissions,
persistableStateAttachmentTypeRegistry,
releasePhase,
settings,
]
);

View file

@ -144,6 +144,9 @@ describe('AllCasesListGeneric', () => {
userProfiles: new Map(),
currentUserProfile: undefined,
selectedColumns: [],
settings: {
displayIncrementalCaseId: false,
},
};
const removeMsFromDate = (value: string) => moment(value).format('YYYY-MM-DDTHH:mm:ss[Z]');
@ -151,7 +154,10 @@ describe('AllCasesListGeneric', () => {
beforeAll(() => {
patchGetComputedStyle();
mockKibana();
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
const {
triggersActionsUi: { actionTypeRegistry },
} = useKibanaMock().services;
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock);
});
@ -183,7 +189,9 @@ describe('AllCasesListGeneric', () => {
it('should render AllCasesList', async () => {
useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true });
renderWithTestingProviders(<AllCasesList />);
renderWithTestingProviders(<AllCasesList />, {
wrapperProps: { settings: { displayIncrementalCaseId: true } },
});
const caseDetailsLinks = await screen.findAllByTestId('case-details-link');
@ -193,6 +201,10 @@ describe('AllCasesListGeneric', () => {
(await screen.findAllByTestId('case-user-profile-avatar-damaged_raccoon'))[0]
).toHaveTextContent('DR');
const incrementalIdTextElements = screen.getAllByTestId('cases-incremental-id-text');
expect(incrementalIdTextElements).toHaveLength(1);
expect(incrementalIdTextElements[0]).toHaveTextContent('#1');
expect((await screen.findAllByTestId('case-table-column-tags-coke'))[0]).toHaveAttribute(
'title',
useGetCasesMockState.data.cases[0].tags[0]
@ -214,6 +226,18 @@ describe('AllCasesListGeneric', () => {
expect(screen.queryByTestId('all-cases-clear-filters-link-icon')).not.toBeInTheDocument();
});
it('should not render incremental id if setting is disabled', async () => {
useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true });
renderWithTestingProviders(<AllCasesList />, {
wrapperProps: { settings: { displayIncrementalCaseId: false } },
});
await screen.findAllByTestId('case-details-link');
const incrementalIdTextElements = screen.queryAllByTestId('cases-incremental-id-text');
expect(incrementalIdTextElements).toHaveLength(0);
});
it("should show a tooltip with the assignee's email when hover over the assignee avatar", async () => {
useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true });
@ -515,7 +539,6 @@ describe('AllCasesListGeneric', () => {
expect(useGetCasesMock).toHaveBeenLastCalledWith({
filterOptions: {
...DEFAULT_FILTER_OPTIONS,
searchFields: ['title', 'description'],
category: ['twix'],
},
queryParams: DEFAULT_QUERY_PARAMS,

View file

@ -46,7 +46,7 @@ export interface AllCasesListProps {
export const AllCasesList = React.memo<AllCasesListProps>(
({ hiddenStatuses = [], isSelectorView = false, onRowClick }) => {
const { owner, permissions } = useCasesContext();
const { owner, permissions, settings } = useCasesContext();
const availableSolutions = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete'));
const isLoading = useIsLoadingCases();
@ -141,6 +141,7 @@ export const AllCasesList = React.memo<AllCasesListProps>(
onRowClick,
disableActions: selectedCases.length > 0,
selectedColumns,
settings,
});
const pagination = useMemo(

View file

@ -79,6 +79,7 @@ describe('use cases add to existing case modal hook', () => {
observables: { enabled: true },
},
releasePhase: 'ga',
settings: { displayIncrementalCaseId: false },
}}
>
{children}

View file

@ -6,7 +6,6 @@
*/
import React from 'react';
import { mount } from 'enzyme';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import type { GetCasesColumn } from './use_cases_columns';
@ -46,6 +45,7 @@ describe('useCasesColumns ', () => {
userProfiles: userProfilesMap,
isSelectorView: false,
selectedColumns: DEFAULT_SELECTED_COLUMNS,
settings: { displayIncrementalCaseId: true },
};
beforeEach(() => {
@ -630,70 +630,56 @@ describe('useCasesColumns ', () => {
describe('ExternalServiceColumn ', () => {
it('Not pushed render', () => {
const wrapper = mount(
<TestProviders>
<ExternalServiceColumn
theCase={useGetCasesMockState.data.cases[0]}
connectors={connectors}
/>
</TestProviders>
renderWithTestingProviders(
<ExternalServiceColumn
theCase={useGetCasesMockState.data.cases[0]}
connectors={connectors}
/>
);
expect(
wrapper.find(`[data-test-subj="case-table-column-external-notPushed"]`).last().exists()
).toBeTruthy();
expect(screen.getByTestId('case-table-column-external-notPushed')).toBeInTheDocument();
});
it('Up to date', () => {
const wrapper = mount(
<TestProviders>
<ExternalServiceColumn
theCase={useGetCasesMockState.data.cases[1]}
connectors={connectors}
/>
</TestProviders>
renderWithTestingProviders(
<ExternalServiceColumn
theCase={useGetCasesMockState.data.cases[1]}
connectors={connectors}
/>
);
expect(
wrapper.find(`[data-test-subj="case-table-column-external-upToDate"]`).last().exists()
).toBeTruthy();
expect(screen.getByTestId('case-table-column-external-upToDate')).toBeInTheDocument();
});
it('Needs update', () => {
const wrapper = mount(
<TestProviders>
<ExternalServiceColumn
theCase={useGetCasesMockState.data.cases[2]}
connectors={connectors}
/>
</TestProviders>
renderWithTestingProviders(
<ExternalServiceColumn
theCase={useGetCasesMockState.data.cases[2]}
connectors={connectors}
/>
);
expect(
wrapper.find(`[data-test-subj="case-table-column-external-requiresUpdate"]`).last().exists()
).toBeTruthy();
expect(screen.getByTestId('case-table-column-external-requiresUpdate')).toBeInTheDocument();
});
it('it does not throw when accessing the icon if the connector type is not registered', () => {
// If the component throws the test will fail
expect(() =>
mount(
<TestProviders>
<ExternalServiceColumn
theCase={useGetCasesMockState.data.cases[2]}
connectors={[
{
id: 'none',
actionTypeId: '.none',
name: 'None',
config: {},
isPreconfigured: false,
isSystemAction: false,
isDeprecated: false,
},
]}
/>
</TestProviders>
renderWithTestingProviders(
<ExternalServiceColumn
theCase={useGetCasesMockState.data.cases[2]}
connectors={[
{
id: 'none',
actionTypeId: '.none',
name: 'None',
config: {},
isPreconfigured: false,
isSystemAction: false,
isDeprecated: false,
},
]}
/>
)
).not.toThrowError();
});

View file

@ -27,7 +27,7 @@ import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import type { ActionConnector } from '../../../common/types/domain';
import { CaseSeverity } from '../../../common/types/domain';
import type { CaseUI } from '../../../common/ui/types';
import type { CaseUI, CasesSettings } from '../../../common/ui/types';
import type { CasesColumnSelection } from './types';
import { getEmptyCellValue } from '../empty_value';
import { FormattedRelativePreferenceDate } from '../formatted_date';
@ -42,6 +42,7 @@ import { severities } from '../severity/config';
import { AssigneesColumn } from './assignees_column';
import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder';
import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration';
import { IncrementalIdText } from '../incremental_id';
type CasesColumns =
| EuiTableActionsColumnType<CaseUI>
@ -66,6 +67,7 @@ export interface GetCasesColumn {
userProfiles: Map<string, UserProfileWithAvatar>;
isSelectorView: boolean;
selectedColumns: CasesColumnSelection[];
settings: CasesSettings;
connectors?: ActionConnector[];
onRowClick?: (theCase: CaseUI) => void;
disableActions?: boolean;
@ -84,6 +86,7 @@ export const useCasesColumns = ({
onRowClick,
disableActions = false,
selectedColumns,
settings,
}: GetCasesColumn): UseCasesColumnsReturnValue => {
const casesColumnsConfig = useCasesColumnsConfiguration(isSelectorView);
const { actions } = useActions({ disableActions });
@ -113,9 +116,14 @@ export const useCasesColumns = ({
const caseDetailsLinkComponent = isSelectorView ? (
theCase.title
) : (
<CaseDetailsLink detailName={theCase.id} title={theCase.title}>
<TruncatedText text={theCase.title} />
</CaseDetailsLink>
<div>
<CaseDetailsLink detailName={theCase.id} title={theCase.title}>
<TruncatedText text={theCase.title} />
</CaseDetailsLink>
{settings.displayIncrementalCaseId && typeof theCase.incrementalId === 'number' ? (
<IncrementalIdText incrementalId={theCase.incrementalId} />
) : null}
</div>
);
return caseDetailsLinkComponent;
@ -334,7 +342,7 @@ export const useCasesColumns = ({
width: '120px',
},
}),
[assignCaseAction, casesColumnsConfig, connectors, isSelectorView, userProfiles]
[assignCaseAction, casesColumnsConfig, connectors, isSelectorView, userProfiles, settings]
);
// we need to extend the columnsDict with the columns of

View file

@ -98,6 +98,7 @@ export const CaseViewPage = React.memo<CaseViewPageProps>(
/>
}
title={caseData.title}
incrementalId={caseData.incrementalId}
>
<CaseActionBar
caseData={caseData}

View file

@ -19,13 +19,18 @@ import type {
CasesFeaturesAllRequired,
CasesFeatures,
CasesPermissions,
CasesSettings,
} from '../../containers/types';
import type { ReleasePhase } from '../types';
import type { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
import type { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry';
import { CasesGlobalComponents } from './cases_global_components';
import { DEFAULT_FEATURES } from '../../../common/constants';
import {
CASES_UI_SETTING_ID_DISPLAY_INCREMENTAL_ID,
DEFAULT_FEATURES,
} from '../../../common/constants';
import { KibanaServices, useKibana } from '../../common/lib/kibana';
import { constructFileKindIdByOwner } from '../../../common/files';
import { DEFAULT_BASE_PATH } from '../../common/navigation';
import type { CasesContextStoreAction } from './state/cases_context_reducer';
@ -45,6 +50,7 @@ export interface CasesContextValue {
features: CasesFeaturesAllRequired;
releasePhase: ReleasePhase;
dispatch: CasesContextValueDispatch;
settings: CasesSettings;
}
export interface CasesContextProps
@ -59,6 +65,7 @@ export interface CasesContextProps
features?: CasesFeatures;
releasePhase?: ReleasePhase;
getFilesClient: (scope: string) => ScopedFilesClient;
settings?: CasesContextValue['settings'];
}
export const CasesContext = React.createContext<CasesContextValue | undefined>(undefined);
@ -79,9 +86,20 @@ export const CasesProvider: FC<
features = {},
releasePhase = 'ga',
getFilesClient,
settings,
},
queryClient = casesQueryClient,
}) => {
const {
settings: { client },
} = useKibana().services;
// UI setting enablement is behind the configuration flag, so will error without this wrapper
let displayIncrementalCaseId = false;
if (KibanaServices.getConfig()?.incrementalId?.enabled) {
displayIncrementalCaseId = client.get(CASES_UI_SETTING_ID_DISPLAY_INCREMENTAL_ID);
}
const [state, dispatch] = useReducer(casesContextReducer, getInitialCasesContextState());
const value: CasesContextValue = useMemo(
@ -114,6 +132,9 @@ export const CasesProvider: FC<
),
releasePhase,
dispatch,
settings: settings ?? {
displayIncrementalCaseId,
},
}),
/**
* We want to trigger a rerender only when the permissions will change.

View file

@ -42,6 +42,7 @@ describe('use cases add to new case flyout hook', () => {
observables: { enabled: true },
},
releasePhase: 'ga',
settings: { displayIncrementalCaseId: false },
}}
>
{children}

View file

@ -34,6 +34,26 @@ describe('HeaderPage', () => {
expect(screen.getByText('Test supplement')).toBeInTheDocument();
});
it('renders the `incremental_id` when provided', () => {
renderWithTestingProviders(
<TestProviders settings={{ displayIncrementalCaseId: true }}>
<HeaderPage border title="Test title" incrementalId={1337} />
</TestProviders>
);
expect(screen.getByText('#1337')).toBeInTheDocument();
});
it('does not render the `incremental_id` when setting disabled', () => {
renderWithTestingProviders(
<TestProviders settings={{ displayIncrementalCaseId: false }}>
<HeaderPage border title="Test title" incrementalId={1337} />
</TestProviders>
);
expect(screen.queryByText('#1337')).not.toBeInTheDocument();
});
it('DOES NOT render the back link when not provided', () => {
const wrapper = mount(
<TestProviders>

View file

@ -12,6 +12,7 @@ import { css } from '@emotion/react';
import { Title } from './title';
import { useCasesContext } from '../cases_context/use_cases_context';
import { IncrementalIdText } from '../incremental_id';
interface HeaderProps {
border?: boolean;
@ -22,6 +23,7 @@ export interface HeaderPageProps extends HeaderProps {
children?: React.ReactNode;
title: string | React.ReactNode;
titleNode?: React.ReactElement;
incrementalId?: number | null;
'data-test-subj'?: string;
}
@ -43,14 +45,18 @@ const HeaderPageComponent: React.FC<HeaderPageProps> = ({
isLoading,
title,
titleNode,
incrementalId,
'data-test-subj': dataTestSubj,
}) => {
const { releasePhase } = useCasesContext();
const { releasePhase, settings } = useCasesContext();
const { euiTheme } = useEuiTheme();
return (
<header css={getHeaderCss(euiTheme, border)} data-test-subj={dataTestSubj}>
<EuiFlexGroup alignItems="center">
<EuiFlexGroup alignItems="center" gutterSize="s">
{settings.displayIncrementalCaseId && incrementalId && (
<IncrementalIdText incrementalId={incrementalId} />
)}
<EuiFlexItem
css={css`
overflow: hidden;

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { renderWithTestingProviders } from '../../common/mock';
import { screen } from '@testing-library/react';
import { IncrementalIdText } from '.';
describe('IncrementalIdText', () => {
it('renders the incremental id', () => {
renderWithTestingProviders(<IncrementalIdText incrementalId={1337} />);
expect(screen.getByTestId('cases-incremental-id-text')).toHaveTextContent('#1337');
});
});

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiText } from '@elastic/eui';
import React from 'react';
import { css } from '@emotion/react';
export const IncrementalIdText = React.memo<{ incrementalId: number }>(({ incrementalId }) => (
<EuiText
color="subdued"
size="s"
data-test-subj="cases-incremental-id-text"
css={css`
user-select: all;
`}
>
{'#'}
{incrementalId}
</EuiText>
));
IncrementalIdText.displayName = 'IncrementalIdText';

View file

@ -26,6 +26,9 @@ jest.mock('../../common/lib/kibana', () => {
KibanaServices: {
...originalModule.KibanaServices,
get: () => mockGetKibanaServices(),
getConfig: () => ({
incrementalId: { enabled: true },
}),
},
useNavigation: jest.fn().mockReturnValue({
getAppUrl: jest.fn((params?: { deepLinkId: string }) => params?.deepLinkId ?? '/test'),

View file

@ -453,8 +453,15 @@ export const cases: CasesUI = [
comments: [],
status: CaseStatuses['in-progress'],
severity: CaseSeverity.MEDIUM,
incrementalId: 1,
},
{
...pushedCase,
updatedAt: laterTime,
id: '2',
totalComment: 0,
comments: [],
},
{ ...pushedCase, updatedAt: laterTime, id: '2', totalComment: 0, comments: [] },
{ ...basicCase, id: '3', totalComment: 0, comments: [] },
{ ...basicCase, id: '4', totalComment: 0, comments: [] },
caseWithAlerts,
@ -621,6 +628,7 @@ export const casesSnake: Cases = [
{
...pushedCaseSnake,
id: '1',
incremental_id: 1,
totalComment: 0,
comments: [],
status: CaseStatuses['in-progress'],

View file

@ -149,4 +149,29 @@ describe('useGetCases', () => {
signal: abortCtrl.signal,
});
});
it('should change search and searchFields for incremental id searches', async () => {
const spyOnGetCases = jest.spyOn(api, 'getCases');
renderHook(() => useGetCases({ filterOptions: { search: '#123' } }), {
wrapper: (props) => (
<TestProviders {...props} settings={{ displayIncrementalCaseId: true }} />
),
});
await waitFor(() => {
expect(spyOnGetCases).toHaveBeenCalled();
});
expect(spyOnGetCases).toBeCalledWith({
filterOptions: {
...DEFAULT_FILTER_OPTIONS,
search: '123',
searchFields: ['incremental_id'],
owner: ['securitySolution'],
},
queryParams: DEFAULT_QUERY_PARAMS,
signal: abortCtrl.signal,
});
});
});

View file

@ -17,6 +17,8 @@ import { useCasesContext } from '../components/cases_context/use_cases_context';
import { useAvailableCasesOwners } from '../components/app/use_available_owners';
import { getAllPermissionsExceptFrom } from '../utils/permissions';
const incrementalIdRegEx = /^#(\d{1,50})\s*$/;
export const initialData: CasesFindResponseUI = {
cases: [],
countClosedCases: 0,
@ -34,7 +36,7 @@ export const useGetCases = (
} = {}
): UseQueryResult<CasesFindResponseUI> => {
const toasts = useToasts();
const { owner } = useCasesContext();
const { owner, settings } = useCasesContext();
const availableSolutions = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete'));
const hasOwner = !!owner.length;
@ -45,6 +47,23 @@ export const useGetCases = (
? { owner: params.filterOptions.owner }
: { owner: initialOwner };
// overrides for incremental_id search
let overrides: Partial<FilterOptions> = {};
if (settings.displayIncrementalCaseId) {
let search = params.filterOptions?.search?.trim();
const isIncrementalIdSearch = incrementalIdRegEx.test(search ?? '');
if (search && isIncrementalIdSearch) {
// extract the number portion of the inc id search: #123 -> 123
search = incrementalIdRegEx.exec(search)?.[1] ?? search;
// search only in `incremental_id` since types with `title`
// and `description` don't overlap
overrides = {
searchFields: ['incremental_id'],
search,
};
}
}
return useQuery(
casesQueriesKeys.cases(params),
({ signal }) => {
@ -53,6 +72,7 @@ export const useGetCases = (
...DEFAULT_FILTER_OPTIONS,
...(params.filterOptions ?? {}),
...ownerFilter,
...overrides,
},
queryParams: {
...DEFAULT_QUERY_PARAMS,

View file

@ -28,6 +28,7 @@ function getConfig(overrides = {}) {
markdownPlugins: { lens: true },
files: { maxSize: 1, allowedMimeTypes: ALLOWED_MIME_TYPES },
stack: { enabled: true },
incrementalId: { enabled: true },
...overrides,
};
}

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SavedObject } from '@kbn/core-saved-objects-server';
import { CaseIdIncrementerAttributesRt } from '../../../common/types/domain/incremental_id/latest';
export interface CaseIdIncrementerPersistedAttributes {
'@timestamp': number;
last_id: number;
updated_at: number;
}
export type CaseIdIncrementerTransformedAttributes = CaseIdIncrementerPersistedAttributes;
export const CaseIdIncrementerTransformedAttributesRt = CaseIdIncrementerAttributesRt;
export type CaseIdIncrementerSavedObject = SavedObject<CaseIdIncrementerPersistedAttributes>;

View file

@ -104,6 +104,11 @@ describe('config validation', () => {
"application/pdf",
],
},
"incrementalId": Object {
"enabled": false,
"taskIntervalMinutes": 10,
"taskStartDelayMinutes": 10,
},
"markdownPlugins": Object {
"lens": true,
},

View file

@ -23,6 +23,26 @@ export const ConfigSchema = schema.object({
stack: schema.object({
enabled: schema.boolean({ defaultValue: true }),
}),
incrementalId: schema.object({
/**
* Whether the incremental id service should be enabled
*/
enabled: schema.boolean({ defaultValue: false }),
/**
* The interval that the task should be scheduled at
*/
taskIntervalMinutes: schema.number({
defaultValue: 10,
min: 5,
}),
/**
* The initial delay the task will be started with
*/
taskStartDelayMinutes: schema.number({
defaultValue: 10,
min: 1,
}),
}),
analytics: schema.object({
index: schema.maybe(
schema.object({

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const ATTACK_DISCOVERY_MAX_OPEN_CASES = 20;

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import { groupAttackDiscoveryAlerts } from '.';
import { attackDiscoveryAlerts } from './index.mock';
import { groupAttackDiscoveryAlerts } from './group_alerts';
import { attackDiscoveryAlerts } from './group_alerts.mock';
describe('groupAttackDiscoveryAlerts', () => {
const getAttackDiscoveryDocument = () => attackDiscoveryAlerts[0];

View file

@ -0,0 +1,73 @@
/*
* 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 { getAttackDiscoveryMarkdown } from '@kbn/elastic-assistant-common';
import { MAX_DOCS_PER_PAGE, MAX_TITLE_LENGTH } from '../../../../common/constants';
import { AttackDiscoveryExpandedAlertsSchema } from './schema';
import type { CaseAlert, CasesGroupedAlerts } from '../types';
import { MAX_OPEN_CASES } from '../constants';
export const groupAttackDiscoveryAlerts = (alerts: CaseAlert[]): CasesGroupedAlerts[] => {
/**
* First we should validate that the alerts array schema complies with the attack discovery object.
*/
const attackDiscoveryAlerts = AttackDiscoveryExpandedAlertsSchema.validate(
alerts,
{},
undefined,
{ stripUnknownKeys: true }
);
if (attackDiscoveryAlerts.length > MAX_OPEN_CASES) {
throw new Error(
`Circuit breaker: Attack discovery alerts grouping would create more than the maximum number of allowed cases ${MAX_OPEN_CASES}.`
);
}
/**
* For each attack discovery alert we would like to create one separate case.
*/
const groupedAlerts = attackDiscoveryAlerts.map((attackAlert) => {
const alertsIndexPattern = attackAlert.kibana.alert.rule.parameters.alertsIndexPattern;
const attackDiscoveryId = attackAlert._id;
const attackDiscovery = attackAlert.kibana.alert.attack_discovery;
const alertIds = attackDiscovery.alert_ids;
const caseTitle = attackDiscovery.title.slice(0, MAX_TITLE_LENGTH);
const caseComments = [
getAttackDiscoveryMarkdown({
attackDiscovery: {
id: attackDiscoveryId,
alertIds,
detailsMarkdown: attackDiscovery.details_markdown,
entitySummaryMarkdown: attackDiscovery.entity_summary_markdown,
mitreAttackTactics: attackDiscovery.mitre_attack_tactics,
summaryMarkdown: attackDiscovery.summary_markdown,
title: caseTitle,
},
replacements: attackDiscovery.replacements?.reduce((acc: Record<string, string>, r) => {
acc[r.uuid] = r.value;
return acc;
}, {}),
}),
].slice(0, MAX_DOCS_PER_PAGE / 2);
/**
* Each attack discovery alert references a list of SIEM alerts that led to the attack.
* These SIEM alerts will be added to the case.
*/
return {
alerts: alertIds.map((siemAlertId) => ({ _id: siemAlertId, _index: alertsIndexPattern })),
grouping: { attack_discovery: attackDiscoveryId },
comments: caseComments,
title: caseTitle,
};
});
return groupedAlerts;
};

View file

@ -5,69 +5,6 @@
* 2.0.
*/
import { getAttackDiscoveryMarkdown } from '@kbn/elastic-assistant-common';
import { MAX_DOCS_PER_PAGE, MAX_TITLE_LENGTH } from '../../../../common/constants';
import { AttackDiscoveryExpandedAlertsSchema } from './schema';
import type { CaseAlert, CasesGroupedAlerts } from '../types';
import { MAX_OPEN_CASES } from '../constants';
export const groupAttackDiscoveryAlerts = (alerts: CaseAlert[]): CasesGroupedAlerts[] => {
/**
* First we should validate that the alerts array schema complies with the attack discovery object.
*/
const attackDiscoveryAlerts = AttackDiscoveryExpandedAlertsSchema.validate(
alerts,
{},
undefined,
{ stripUnknownKeys: true }
);
if (attackDiscoveryAlerts.length > MAX_OPEN_CASES) {
throw new Error(
`Circuit breaker: Attack discovery alerts grouping would create more than the maximum number of allowed cases ${MAX_OPEN_CASES}.`
);
}
/**
* For each attack discovery alert we would like to create one separate case.
*/
const groupedAlerts = attackDiscoveryAlerts.map((attackAlert) => {
const alertsIndexPattern = attackAlert.kibana.alert.rule.parameters.alertsIndexPattern;
const attackDiscoveryId = attackAlert._id;
const attackDiscovery = attackAlert.kibana.alert.attack_discovery;
const alertIds = attackDiscovery.alert_ids;
const caseTitle = attackDiscovery.title.slice(0, MAX_TITLE_LENGTH);
const caseComments = [
getAttackDiscoveryMarkdown({
attackDiscovery: {
id: attackDiscoveryId,
alertIds,
detailsMarkdown: attackDiscovery.details_markdown,
entitySummaryMarkdown: attackDiscovery.entity_summary_markdown,
mitreAttackTactics: attackDiscovery.mitre_attack_tactics,
summaryMarkdown: attackDiscovery.summary_markdown,
title: caseTitle,
},
replacements: attackDiscovery.replacements?.reduce((acc: Record<string, string>, r) => {
acc[r.uuid] = r.value;
return acc;
}, {}),
}),
].slice(0, MAX_DOCS_PER_PAGE / 2);
/**
* Each attack discovery alert references a list of SIEM alerts that led to the attack.
* These SIEM alerts will be added to the case.
*/
return {
alerts: alertIds.map((siemAlertId) => ({ _id: siemAlertId, _index: alertsIndexPattern })),
grouping: { attack_discovery: attackDiscoveryId },
comments: caseComments,
title: caseTitle,
};
});
return groupedAlerts;
};
export * from './constants';
export * from './group_alerts';
export * from './types';

View file

@ -7,6 +7,10 @@
import type { TypeOf } from '@kbn/config-schema';
import type { AttackDiscoveryExpandedAlertsSchema } from './schema';
import type {
AttackDiscoveryExpandedAlertSchema,
AttackDiscoveryExpandedAlertsSchema,
} from './schema';
export type AttackDiscoveryExpandedAlert = TypeOf<typeof AttackDiscoveryExpandedAlertSchema>;
export type AttackDiscoveryExpandedAlerts = TypeOf<typeof AttackDiscoveryExpandedAlertsSchema>;

View file

@ -2768,7 +2768,7 @@ describe('CasesConnectorExecutor', () => {
alerts: allAlerts,
groupingBy: ['host.name'],
// MAX_OPEN_CASES < maximumCasesToOpen
maximumCasesToOpen: 20,
maximumCasesToOpen: 30,
});
expect(mockGetRecordId).toHaveBeenCalledTimes(1);
@ -2786,7 +2786,7 @@ describe('CasesConnectorExecutor', () => {
alerts: allAlerts,
groupingBy: ['host.name'],
// MAX_OPEN_CASES < maximumCasesToOpen
maximumCasesToOpen: 20,
maximumCasesToOpen: 30,
});
expect(mockGetCaseId).toHaveBeenCalledTimes(1);
@ -2805,7 +2805,7 @@ describe('CasesConnectorExecutor', () => {
alerts: allAlerts,
groupingBy: ['host.name'],
// MAX_OPEN_CASES < maximumCasesToOpen
maximumCasesToOpen: 20,
maximumCasesToOpen: 30,
});
expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1);
@ -2832,11 +2832,11 @@ describe('CasesConnectorExecutor', () => {
alerts: allAlerts,
groupingBy: ['host.name'],
// MAX_OPEN_CASES < maximumCasesToOpen
maximumCasesToOpen: 20,
maximumCasesToOpen: 30,
});
expect(mockLogger.warn).toHaveBeenCalledWith(
`[CasesConnector][CasesConnectorExecutor][applyCircuitBreakers] Circuit breaker: Grouping definition would create more than the maximum number of allowed cases 10. Falling back to one case.`,
`[CasesConnector][CasesConnectorExecutor][applyCircuitBreakers] Circuit breaker: Grouping definition would create more than the maximum number of allowed cases 20. Falling back to one case.`,
{ labels: {}, tags: ['cases-connector', 'rule:rule-test-id'] }
);
});

View file

@ -8,7 +8,7 @@
import { CustomFieldTypes } from '../../../common/types/domain';
export const MAX_CONCURRENT_ES_REQUEST = 5;
export const MAX_OPEN_CASES = 10;
export const MAX_OPEN_CASES = 20;
export const DEFAULT_MAX_OPEN_CASES = 5;
export const INITIAL_ORACLE_RECORD_COUNTER = 1;

View file

@ -12,7 +12,10 @@ import { AlertConsumers } from '@kbn/rule-data-utils';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import type { Logger } from '@kbn/core/server';
import { ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID } from '@kbn/elastic-assistant-common';
import { attackDiscoveryAlerts } from './attack_discovery/index.mock';
import { attackDiscoveryAlerts } from './attack_discovery/group_alerts.mock';
import { DEFAULT_MAX_OPEN_CASES } from './constants';
import type { AttackDiscoveryExpandedAlert } from './attack_discovery';
import { ATTACK_DISCOVERY_MAX_OPEN_CASES } from './attack_discovery';
describe('getCasesConnectorType', () => {
const mockLogger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
@ -424,6 +427,36 @@ describe('getCasesConnectorType', () => {
recovered: { data: [], count: 0 },
};
it('returns `internallyManagedAlerts` set to `true`', () => {
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts: alertsMock,
rule: attackDiscoveryRule,
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.internallyManagedAlerts).toBe(true);
});
it('returns `maximumCasesToOpen` set to `ATTACK_DISCOVERY_MAX_OPEN_CASES`', () => {
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts: alertsMock,
rule: attackDiscoveryRule,
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.maximumCasesToOpen).toBe(
ATTACK_DISCOVERY_MAX_OPEN_CASES
);
});
it('correctly groups attack discovery alerts', () => {
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
@ -494,6 +527,35 @@ describe('getCasesConnectorType', () => {
expect(connectorParams.subActionParams.internallyManagedAlerts).toBe(true);
});
it('correctly fallsback to general flow if alerts count is above the limit', () => {
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
const manyAttackDiscoveryAlerts = new Array<AttackDiscoveryExpandedAlert>(
ATTACK_DISCOVERY_MAX_OPEN_CASES + 1
).fill(attackDiscoveryAlerts[0]);
const manyAlerts = {
all: { data: [...manyAttackDiscoveryAlerts], count: manyAttackDiscoveryAlerts.length },
new: { data: [...manyAttackDiscoveryAlerts], count: manyAttackDiscoveryAlerts.length },
ongoing: { data: [], count: 0 },
recovered: { data: [], count: 0 },
};
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts: manyAlerts,
rule: attackDiscoveryRule,
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.groupedAlerts).toBeNull();
expect(connectorParams.subActionParams.internallyManagedAlerts).toBe(false);
expect(connectorParams.subActionParams.maximumCasesToOpen).toBe(DEFAULT_MAX_OPEN_CASES);
expect(mockLogger.error).toBeCalledWith(
'Could not setup grouped Attack Discovery alerts, because of error: Error: Circuit breaker: Attack discovery alerts grouping would create more than the maximum number of allowed cases 20.'
);
});
it('correctly fallsback to general flow if alerts schema does not pass validation', () => {
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
@ -507,6 +569,7 @@ describe('getCasesConnectorType', () => {
expect(connectorParams.subActionParams.groupedAlerts).toBeNull();
expect(connectorParams.subActionParams.internallyManagedAlerts).toBe(false);
expect(connectorParams.subActionParams.maximumCasesToOpen).toBe(DEFAULT_MAX_OPEN_CASES);
expect(mockLogger.error).toBeCalledWith(
'Could not setup grouped Attack Discovery alerts, because of error: Error: [0.kibana.alert.attack_discovery.alert_ids]: expected value of type [array] but got [undefined]'
);

View file

@ -38,7 +38,7 @@ import {
} from './schema';
import type { CasesClient } from '../../client';
import { constructRequiredKibanaPrivileges } from './utils';
import { groupAttackDiscoveryAlerts } from './attack_discovery';
import { ATTACK_DISCOVERY_MAX_OPEN_CASES, groupAttackDiscoveryAlerts } from './attack_discovery';
interface GetCasesConnectorTypeArgs {
getCasesClient: (request: KibanaRequest) => Promise<CasesClient>;
@ -109,10 +109,12 @@ export const getCasesConnectorAdapter = ({
*/
let internallyManagedAlerts = false;
let groupedAlerts: CasesGroupedAlerts[] | null = null;
let maximumCasesToOpen = DEFAULT_MAX_OPEN_CASES;
if (rule.ruleTypeId === ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID) {
try {
groupedAlerts = groupAttackDiscoveryAlerts(caseAlerts);
internallyManagedAlerts = true;
maximumCasesToOpen = ATTACK_DISCOVERY_MAX_OPEN_CASES;
} catch (error) {
logger.error(
`Could not setup grouped Attack Discovery alerts, because of error: ${error}`
@ -134,7 +136,7 @@ export const getCasesConnectorAdapter = ({
owner,
reopenClosedCases: params.subActionParams.reopenClosedCases,
timeWindow: params.subActionParams.timeWindow,
maximumCasesToOpen: DEFAULT_MAX_OPEN_CASES,
maximumCasesToOpen,
templateId: params.subActionParams.templateId,
internallyManagedAlerts,
};

View file

@ -193,13 +193,13 @@ describe('CasesConnectorRunParamsSchema', () => {
).toThrow();
});
it('does not accept maximumCasesToOpen to be more than 10', () => {
it('does not accept maximumCasesToOpen to be more than 20', () => {
const params = getParams();
expect(() =>
CasesConnectorRunParamsSchema.validate({
...params,
maximumCasesToOpen: 11,
maximumCasesToOpen: 21,
})
).toThrow();
});

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