diff --git a/.buildkite/scripts/common/env.sh b/.buildkite/scripts/common/env.sh index ac80a66d33fa0a..cd33cdc714cbef 100755 --- a/.buildkite/scripts/common/env.sh +++ b/.buildkite/scripts/common/env.sh @@ -36,7 +36,12 @@ export ELASTIC_APM_ENVIRONMENT=ci export ELASTIC_APM_TRANSACTION_SAMPLE_RATE=0.1 if is_pr; then - export ELASTIC_APM_ACTIVE=false + if [[ "${GITHUB_PR_LABELS:-}" == *"ci:collect-apm"* ]]; then + export ELASTIC_APM_ACTIVE=true + else + export ELASTIC_APM_ACTIVE=false + fi + export CHECKS_REPORTER_ACTIVE=true # These can be removed once we're not supporting Jenkins and Buildkite at the same time diff --git a/.buildkite/scripts/lifecycle/pre_command.sh b/.buildkite/scripts/lifecycle/pre_command.sh index be31bb74ef6688..cae6a07708d467 100755 --- a/.buildkite/scripts/lifecycle/pre_command.sh +++ b/.buildkite/scripts/lifecycle/pre_command.sh @@ -4,16 +4,17 @@ set -euo pipefail source .buildkite/scripts/common/util.sh -node .buildkite/scripts/lifecycle/print_agent_links.js || true - -echo '--- Job Environment Setup' +BUILDKITE_TOKEN="$(retry 5 5 vault read -field=buildkite_token_all_jobs secret/kibana-issues/dev/buildkite-ci)" +export BUILDKITE_TOKEN +echo '--- Install buildkite dependencies' cd '.buildkite' retry 5 15 yarn install cd - -BUILDKITE_TOKEN="$(retry 5 5 vault read -field=buildkite_token_all_jobs secret/kibana-issues/dev/buildkite-ci)" -export BUILDKITE_TOKEN +node .buildkite/scripts/lifecycle/print_agent_links.js || true + +echo '--- Job Environment Setup' # Set up a custom ES Snapshot Manifest if one has been specified for this build { diff --git a/api_docs/charts.json b/api_docs/charts.json index 5d4f047a247e2d..83a2a93df42a16 100644 --- a/api_docs/charts.json +++ b/api_docs/charts.json @@ -229,7 +229,7 @@ ", xAccessor: string | number | ", "AccessorFn", ") => ({ x: selectedRange }: ", - "XYBrushArea", + "XYBrushEvent", ") => ", { "pluginId": "charts", @@ -4266,4 +4266,4 @@ } ] } -} \ No newline at end of file +} diff --git a/docs/api/short-urls.asciidoc b/docs/api/short-urls.asciidoc new file mode 100644 index 00000000000000..ded639c897f3f9 --- /dev/null +++ b/docs/api/short-urls.asciidoc @@ -0,0 +1,9 @@ +[[short-urls-api]] +== Short URLs APIs + +Manage {kib} short URLs. + +include::short-urls/create-short-url.asciidoc[] +include::short-urls/get-short-url.asciidoc[] +include::short-urls/delete-short-url.asciidoc[] +include::short-urls/resolve-short-url.asciidoc[] diff --git a/docs/api/short-urls/create-short-url.asciidoc b/docs/api/short-urls/create-short-url.asciidoc new file mode 100644 index 00000000000000..a9138a4c555da6 --- /dev/null +++ b/docs/api/short-urls/create-short-url.asciidoc @@ -0,0 +1,86 @@ +[[short-urls-api-create]] +=== Create short URL API +++++ +Create short URL +++++ + +experimental[] Create a {kib} short URL. {kib} URLs may be long and cumbersome, short URLs are much +easier to remember and share. + +Short URLs are created by specifying the locator ID and locator parameters. When a short URL is +resolved, the locator ID and locator parameters are used to redirect user to the right {kib} page. + + +[[short-urls-api-create-request]] +==== Request + +`POST :/api/short_url` + + +[[short-urls-api-create-request-body]] +==== Request body + +`locatorId`:: + (Required, string) ID of the locator. + +`params`:: + (Required, object) Object which contains all necessary parameters for the given locator to resolve + to a {kib} location. ++ +WARNING: When you create a short URL, locator params are not validated, which allows you to pass +arbitrary and ill-formed data into the API that can break {kib}. Make sure +any data that you send to the API is properly formed. + +`slug`:: + (Optional, string) A custom short URL slug. Slug is the part of the short URL that identifies it. + You can provide a custom slug which consists of latin alphabet letters, numbers and `-._` + characters. The slug must be at least 3 characters long, but no longer than 255 characters. + +`humanReadableSlug`:: + (Optional, boolean) When the `slug` parameter is omitted, the API will generate a random + human-readable slug, if `humanReadableSlug` is set to `true`. + + +[[short-urls-api-create-response-codes]] +==== Response code + +`200`:: + Indicates a successful call. + + +[[short-urls-api-create-example]] +==== Example + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/short_url -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d ' +{ + "locatorId": "LOCATOR_ID", + "params": {}, + "humanReadableSlug": true +}' +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", <1> + "slug": "adjective-adjective-noun", <2> + "locator": { + "id": "LOCATOR_ID", + "version": "x.x.x", <3> + "state": {} <4> + }, + "accessCount": 0, + "accessDate": 1632680100000, + "createDate": 1632680100000 +} +-------------------------------------------------- + +<1> A random ID is automatically generated. +<2> A random human-readable slug is automatically generated if the `humanReadableSlug` parameter is set to `true`. If set to `false` a random short string is generated. +<3> The version of {kib} when short URL was created is stored. +<4> Locator params provided as `params` property are stored. diff --git a/docs/api/short-urls/delete-short-url.asciidoc b/docs/api/short-urls/delete-short-url.asciidoc new file mode 100644 index 00000000000000..f405ccf1a7472b --- /dev/null +++ b/docs/api/short-urls/delete-short-url.asciidoc @@ -0,0 +1,39 @@ +[[short-urls-api-delete]] +=== Delete short URL API +++++ +Delete short URL +++++ + +experimental[] Delete a {kib} short URL. + + +[[short-urls-api-delete-request]] +==== Request + +`DELETE :/api/short_url/` + + +[[short-urls-api-delete-path-params]] +==== Path parameters + +`id`:: + (Required, string) The short URL ID that you want to remove. + + +[[short-urls-api-delete-response-codes]] +==== Response code + +`200`:: + Indicates a successful call. + + +[[short-urls-api-delete-example]] +==== Example + +Delete a short URL `12345` ID: + +[source,sh] +-------------------------------------------------- +$ curl -X DELETE api/short_url/12345 +-------------------------------------------------- +// KIBANA diff --git a/docs/api/short-urls/get-short-url.asciidoc b/docs/api/short-urls/get-short-url.asciidoc new file mode 100644 index 00000000000000..bf4303d442fb06 --- /dev/null +++ b/docs/api/short-urls/get-short-url.asciidoc @@ -0,0 +1,56 @@ +[[short-urls-api-get]] +=== Get short URL API +++++ +Get short URL +++++ + +experimental[] Retrieve a single {kib} short URL. + +[[short-urls-api-get-request]] +==== Request + +`GET :/api/short_url/` + + +[[short-urls-api-get-params]] +==== Path parameters + +`id`:: + (Required, string) The ID of the short URL. + + +[[short-urls-api-get-codes]] +==== Response code + +`200`:: + Indicates a successful call. + + +[[short-urls-api-get-example]] +==== Example + +Retrieve the short URL with the `12345` ID: + +[source,sh] +-------------------------------------------------- +$ curl -X GET api/short_url/12345 +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "id": "12345", + "slug": "adjective-adjective-noun", + "locator": { + "id": "LOCATOR_ID", + "version": "x.x.x", + "state": {} + }, + "accessCount": 0, + "accessDate": 1632680100000, + "createDate": 1632680100000 +} +-------------------------------------------------- diff --git a/docs/api/short-urls/resolve-short-url.asciidoc b/docs/api/short-urls/resolve-short-url.asciidoc new file mode 100644 index 00000000000000..32ad7ba7625c07 --- /dev/null +++ b/docs/api/short-urls/resolve-short-url.asciidoc @@ -0,0 +1,56 @@ +[[short-urls-api-resolve]] +=== Resolve short URL API +++++ +Resolve short URL +++++ + +experimental[] Resolve a single {kib} short URL by its slug. + +[[short-urls-api-resolve-request]] +==== Request + +`GET :/api/short_url/_slug/` + + +[[short-urls-api-resolve-params]] +==== Path parameters + +`slug`:: + (Required, string) The slug of the short URL. + + +[[short-urls-api-resolve-codes]] +==== Response code + +`200`:: + Indicates a successful call. + + +[[short-urls-api-resolve-example]] +==== Example + +Retrieve the short URL with the `hello-world` ID: + +[source,sh] +-------------------------------------------------- +$ curl -X GET api/short_url/_slug/hello-world +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "id": "12345", + "slug": "hello-world", + "locator": { + "id": "LOCATOR_ID", + "version": "x.x.x", + "state": {} + }, + "accessCount": 0, + "accessDate": 1632680100000, + "createDate": 1632680100000 +} +-------------------------------------------------- diff --git a/docs/api/url-shortening.asciidoc b/docs/api/url-shortening.asciidoc deleted file mode 100644 index ffe1d925e5dcb3..00000000000000 --- a/docs/api/url-shortening.asciidoc +++ /dev/null @@ -1,62 +0,0 @@ -[[url-shortening-api]] -== Shorten URL API -++++ -Shorten URL -++++ - -experimental[] Convert a {kib} URL into a token. {kib} URLs contain the state of the application, which makes them long and cumbersome. -Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the {kib} URL. - -Short URLs are designed to make sharing {kib} URLs easier. - -[float] -[[url-shortening-api-request]] -=== Request - -`POST :/api/shorten_url` - -[float] -[[url-shortening-api-request-body]] -=== Request body - -`url`:: - (Required, string) The {kib} URL that you want to shorten, relative to `/app/kibana`. - -[float] -[[url-shortening-api-response-body]] -=== Response body - -urlId:: A top-level property that contains the shortened URL token for the provided request body. - -[float] -[[url-shortening-api-codes]] -=== Response code - -`200`:: - Indicates a successful call. - -[float] -[[url-shortening-api-example]] -=== Example - -[source,sh] --------------------------------------------------- -$ curl -X POST api/shorten_url -{ - "url": "/app/kibana#/dashboard?_g=()&_a=(description:'',filters:!(),fullScreenMode:!f,options:(hidePanelTitles:!f,useMargins:!t),panels:!((embeddableConfig:(),gridData:(h:15,i:'1',w:24,x:0,y:0),id:'8f4d0c00-4c86-11e8-b3d7-01146121b73d',panelIndex:'1',type:visualization,version:'7.0.0-alpha1')),query:(language:lucene,query:''),timeRestore:!f,title:'New%20Dashboard',viewMode:edit)" -} --------------------------------------------------- -// KIBANA - -The API returns the following: - -[source,sh] --------------------------------------------------- -{ - "urlId": "f73b295ff92718b26bc94edac766d8e3" -} --------------------------------------------------- - -For easy sharing, construct the shortened {kib} URL: - -`http://localhost:5601/goto/f73b295ff92718b26bc94edac766d8e3` diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 0723040d08da35..9581848be9e539 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -346,7 +346,7 @@ The plugin exposes the static DefaultEditorController class to consume. |{kib-repo}blob/{branch}/x-pack/plugins/apm/readme.md[apm] -|Local setup documentation +|undefined |{kib-repo}blob/{branch}/x-pack/plugins/banners/README.md[banners] @@ -358,7 +358,8 @@ The plugin exposes the static DefaultEditorController class to consume. |{kib-repo}blob/{branch}/x-pack/plugins/cases/README.md[cases] -|Case management in Kibana +|[![Issues][issues-shield]][issues-url] +[![Pull Requests][pr-shield]][pr-url] |{kib-repo}blob/{branch}/x-pack/plugins/cloud/README.md[cloud] diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index c8ccdfeedb83f7..5871d7df6a7c5f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -145,6 +145,9 @@ readonly links: { readonly networkMap: string; readonly troubleshootGaps: string; }; + readonly securitySolution: { + readonly trustedApps: string; + }; readonly query: { readonly eql: string; readonly kueryQuerySyntax: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index 04c2495cf3f1d6..a4e842c317256b 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,4 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly customLinks: string;
readonly droppedTransactionSpans: string;
readonly upgrading: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Readonly<{
guide: string;
infrastructureThreshold: string;
logsThreshold: string;
metricsThreshold: string;
monitorStatus: string;
monitorUptime: string;
tlsCertificate: string;
uptimeDurationAnomaly: string;
}>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly spaces: Readonly<{
kibanaLegacyUrlAliases: string;
kibanaDisableLegacyUrlAliasesApi: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
readonly ecs: {
readonly guide: string;
};
readonly clients: {
readonly guide: string;
readonly goOverview: string;
readonly javaIndex: string;
readonly jsIntro: string;
readonly netGuide: string;
readonly perlGuide: string;
readonly phpGuide: string;
readonly pythonGuide: string;
readonly rubyOverview: string;
readonly rustGuide: string;
};
} | | - +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly customLinks: string;
readonly droppedTransactionSpans: string;
readonly upgrading: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly securitySolution: {
readonly trustedApps: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Readonly<{
guide: string;
infrastructureThreshold: string;
logsThreshold: string;
metricsThreshold: string;
monitorStatus: string;
monitorUptime: string;
tlsCertificate: string;
uptimeDurationAnomaly: string;
}>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
readonly ecs: {
readonly guide: string;
};
readonly clients: {
readonly guide: string;
readonly goOverview: string;
readonly javaIndex: string;
readonly jsIntro: string;
readonly netGuide: string;
readonly perlGuide: string;
readonly phpGuide: string;
readonly pythonGuide: string;
readonly rubyOverview: string;
readonly rustGuide: string;
};
} | | diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 92adbaf97d8c52..93d0ee3d2cab64 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -35,10 +35,14 @@ a| <> | Add a message to a Kibana log. -a| <> +a| <> | Create an incident in ServiceNow. +a| <> + +| Create a security incident in ServiceNow. + a| <> | Send a message to a Slack channel or user. diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 6fd83e8af3d155..6bac5e7940dbb3 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -426,6 +426,7 @@ because requests can be spread across all shard copies. However, results might be inconsistent because different shards might be in different refresh states. [[search-includefrozen]]`search:includeFrozen`:: +**This setting is deprecated and will not be supported as of 9.0.** Includes {ref}/frozen-indices.html[frozen indices] in results. Searching through frozen indices might increase the search time. This setting is off by default. Users must opt-in to include frozen indices. diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc new file mode 100644 index 00000000000000..4556746284d5bd --- /dev/null +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -0,0 +1,89 @@ +[role="xpack"] +[[servicenow-sir-action-type]] +=== ServiceNow connector and action +++++ +ServiceNow SecOps +++++ + +The ServiceNow SecOps connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow security incidents. + +[float] +[[servicenow-sir-connector-configuration]] +==== Connector configuration + +ServiceNow SecOps connectors have the following configuration properties. + +Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. +URL:: ServiceNow instance URL. +Username:: Username for HTTP Basic authentication. +Password:: Password for HTTP Basic authentication. + +The ServiceNow user requires at minimum read, create, and update access to the Security Incident table and read access to the https://docs.servicenow.com/bundle/paris-platform-administration/page/administer/localization/reference/r_ChoicesTable.html[sys_choice]. If you don't provide access to sys_choice, then the choices will not render. + +[float] +[[servicenow-sir-connector-networking-configuration]] +==== Connector networking configuration + +Use the <> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations. + +[float] +[[Preconfigured-servicenow-sir-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-servicenow-sir: + name: preconfigured-servicenow-connector-type + actionTypeId: .servicenow-sir + config: + apiUrl: https://dev94428.service-now.com/ + secrets: + username: testuser + password: passwordkeystorevalue +-- + +Config defines information for the connector type. + +`apiUrl`:: An address that corresponds to *URL*. + +Secrets defines sensitive information for the connector type. + +`username`:: A string that corresponds to *Username*. +`password`:: A string that corresponds to *Password*. Should be stored in the <>. + +[float] +[[define-servicenow-sir-ui]] +==== Define connector in Stack Management + +Define ServiceNow SecOps connector properties. + +[role="screenshot"] +image::management/connectors/images/servicenow-sir-connector.png[ServiceNow SecOps connector] + +Test ServiceNow SecOps action parameters. + +[role="screenshot"] +image::management/connectors/images/servicenow-sir-params-test.png[ServiceNow SecOps params test] + +[float] +[[servicenow-sir-action-configuration]] +==== Action configuration + +ServiceNow SecOps actions have the following configuration properties. + +Short description:: A short description for the incident, used for searching the contents of the knowledge base. +Source Ips:: A list of source IPs related to the incident. The IPs will be added as observables to the security incident. +Destination Ips:: A list of destination IPs related to the incident. The IPs will be added as observables to the security incident. +Malware URLs:: A list of malware URLs related to the incident. The URLs will be added as observables to the security incident. +Malware Hashes:: A list of malware hashes related to the incident. The hashes will be added as observables to the security incident. +Priority:: The priority of the incident. +Category:: The category of the incident. +Subcategory:: The subcategory of the incident. +Description:: The details about the incident. +Additional comments:: Additional information for the client, such as how to troubleshoot the issue. + +[float] +[[configuring-servicenow-sir]] +==== Configure ServiceNow SecOps + +ServiceNow offers free https://developer.servicenow.com/dev.do#!/guides/madrid/now-platform/pdi-guide/obtaining-a-pdi[Personal Developer Instances], which you can use to test incidents. diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index 3a4134cbf982e7..cf5244a9e3f9e1 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -2,16 +2,16 @@ [[servicenow-action-type]] === ServiceNow connector and action ++++ -ServiceNow +ServiceNow ITSM ++++ -The ServiceNow connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents. +The ServiceNow ITSM connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents. [float] [[servicenow-connector-configuration]] ==== Connector configuration -ServiceNow connectors have the following configuration properties. +ServiceNow ITSM connectors have the following configuration properties. Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. URL:: ServiceNow instance URL. @@ -55,12 +55,12 @@ Secrets defines sensitive information for the connector type. [[define-servicenow-ui]] ==== Define connector in Stack Management -Define ServiceNow connector properties. +Define ServiceNow ITSM connector properties. [role="screenshot"] image::management/connectors/images/servicenow-connector.png[ServiceNow connector] -Test ServiceNow action parameters. +Test ServiceNow ITSM action parameters. [role="screenshot"] image::management/connectors/images/servicenow-params-test.png[ServiceNow params test] @@ -69,11 +69,13 @@ image::management/connectors/images/servicenow-params-test.png[ServiceNow params [[servicenow-action-configuration]] ==== Action configuration -ServiceNow actions have the following configuration properties. +ServiceNow ITSM actions have the following configuration properties. Urgency:: The extent to which the incident resolution can delay. Severity:: The severity of the incident. Impact:: The effect an incident has on business. Can be measured by the number of affected users or by how critical it is to the business in question. +Category:: The category of the incident. +Subcategory:: The category of the incident. Short description:: A short description for the incident, used for searching the contents of the knowledge base. Description:: The details about the incident. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. diff --git a/docs/management/connectors/images/servicenow-sir-params-test.png b/docs/management/connectors/images/servicenow-sir-params-test.png index 16ea83c60b3c32..80103a4272bfac 100644 Binary files a/docs/management/connectors/images/servicenow-sir-params-test.png and b/docs/management/connectors/images/servicenow-sir-params-test.png differ diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 033b1c3ac150ea..536d05705181dc 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -6,6 +6,7 @@ include::action-types/teams.asciidoc[] include::action-types/pagerduty.asciidoc[] include::action-types/server-log.asciidoc[] include::action-types/servicenow.asciidoc[] +include::action-types/servicenow-sir.asciidoc[] include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] diff --git a/docs/settings/general-infra-logs-ui-settings.asciidoc b/docs/settings/general-infra-logs-ui-settings.asciidoc index 282239dcf166c4..1e6dcf012206b9 100644 --- a/docs/settings/general-infra-logs-ui-settings.asciidoc +++ b/docs/settings/general-infra-logs-ui-settings.asciidoc @@ -1,31 +1,28 @@ -[cols="2*<"] -|=== -| `xpack.infra.enabled` - | deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] - Set to `false` to disable the Logs and Metrics app plugin {kib}. Defaults to `true`. -| `xpack.infra.sources.default.logAlias` - | Index pattern for matching indices that contain log data. Defaults to `filebeat-*,kibana_sample_data_logs*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. +`xpack.infra.enabled`:: +deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] +Set to `false` to disable the Logs and Metrics app plugin {kib}. Defaults to `true`. -| `xpack.infra.sources.default.metricAlias` - | Index pattern for matching indices that contain Metricbeat data. Defaults to `metricbeat-*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. +`xpack.infra.sources.default.logAlias`:: +Index pattern for matching indices that contain log data. Defaults to `filebeat-*,kibana_sample_data_logs*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. -| `xpack.infra.sources.default.fields.timestamp` - | Timestamp used to sort log entries. Defaults to `@timestamp`. +`xpack.infra.sources.default.metricAlias`:: +Index pattern for matching indices that contain Metricbeat data. Defaults to `metricbeat-*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. -| `xpack.infra.sources.default.fields.message` - | Fields used to display messages in the Logs app. Defaults to `['message', '@message']`. +`xpack.infra.sources.default.fields.timestamp`:: +Timestamp used to sort log entries. Defaults to `@timestamp`. -| `xpack.infra.sources.default.fields.tiebreaker` - | Field used to break ties between two entries with the same timestamp. Defaults to `_doc`. +`xpack.infra.sources.default.fields.message`:: +Fields used to display messages in the Logs app. Defaults to `['message', '@message']`. -| `xpack.infra.sources.default.fields.host` - | Field used to identify hosts. Defaults to `host.name`. +`xpack.infra.sources.default.fields.tiebreaker`:: +Field used to break ties between two entries with the same timestamp. Defaults to `_doc`. -| `xpack.infra.sources.default.fields.container` - | Field used to identify Docker containers. Defaults to `container.id`. +`xpack.infra.sources.default.fields.host`:: +Field used to identify hosts. Defaults to `host.name`. -| `xpack.infra.sources.default.fields.pod` - | Field used to identify Kubernetes pods. Defaults to `kubernetes.pod.uid`. +`xpack.infra.sources.default.fields.container`:: +Field used to identify Docker containers. Defaults to `container.id`. -|=== +`xpack.infra.sources.default.fields.pod`:: +Field used to identify Kubernetes pods. Defaults to `kubernetes.pod.uid`. \ No newline at end of file diff --git a/docs/settings/ml-settings.asciidoc b/docs/settings/ml-settings.asciidoc index 59fa236e08275b..e67876c76df0db 100644 --- a/docs/settings/ml-settings.asciidoc +++ b/docs/settings/ml-settings.asciidoc @@ -11,18 +11,14 @@ enabled by default. [[general-ml-settings-kb]] ==== General {ml} settings -[cols="2*<"] -|=== -| `xpack.ml.enabled` {ess-icon} - | deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] - Set to `true` (default) to enable {kib} {ml-features}. + - + - If set to `false` in `kibana.yml`, the {ml} icon is hidden in this {kib} - instance. If `xpack.ml.enabled` is set to `true` in `elasticsearch.yml`, however, - you can still use the {ml} APIs. To disable {ml} entirely, see the - {ref}/ml-settings.html[{es} {ml} settings]. - -|=== +`xpack.ml.enabled` {ess-icon}:: +deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] +Set to `true` (default) to enable {kib} {ml-features}. + ++ +If set to `false` in `kibana.yml`, the {ml} icon is hidden in this {kib} +instance. If `xpack.ml.enabled` is set to `true` in `elasticsearch.yml`, however, +you can still use the {ml} APIs. To disable {ml} entirely, refer to +{ref}/ml-settings.html[{es} {ml} settings]. [[advanced-ml-settings-kb]] ==== Advanced {ml} settings diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index 560f2d850c6d59..af10430ef8d01e 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -79,11 +79,6 @@ The protocol for accessing {kib}, typically `http` or `https`. [[xpack-kibanaServer-hostname]] `xpack.reporting.kibanaServer.hostname`:: The hostname for accessing {kib}, if different from the <> value. -NOTE: Reporting authenticates requests on the {kib} page only when the hostname matches the -<> setting. Therefore Reporting fails if the -set value redirects to another server. For that reason, `"0"` is an invalid setting -because, in the Reporting browser, it becomes an automatic redirect to `"0.0.0.0"`. - [float] [[reporting-job-queue-settings]] ==== Background job settings diff --git a/docs/settings/search-sessions-settings.asciidoc b/docs/settings/search-sessions-settings.asciidoc index abd6a8f12b5689..7b03cd23a90232 100644 --- a/docs/settings/search-sessions-settings.asciidoc +++ b/docs/settings/search-sessions-settings.asciidoc @@ -7,37 +7,26 @@ Configure the search session settings in your `kibana.yml` configuration file. +`xpack.data_enhanced.search.sessions.enabled` {ess-icon}:: +Set to `true` (default) to enable search sessions. -[cols="2*<"] -|=== -a| `xpack.data_enhanced.` -`search.sessions.enabled` {ess-icon} -| Set to `true` (default) to enable search sessions. +`xpack.data_enhanced.search.sessions.trackingInterval` {ess-icon}:: +The frequency for updating the state of a search session. The default is `10s`. -a| `xpack.data_enhanced.` -`search.sessions.trackingInterval` {ess-icon} -| The frequency for updating the state of a search session. The default is `10s`. - -a| `xpack.data_enhanced.` -`search.sessions.pageSize` {ess-icon} -| How many search sessions {kib} processes at once while monitoring +`xpack.data_enhanced.search.sessions.pageSize` {ess-icon}:: +How many search sessions {kib} processes at once while monitoring session progress. The default is `100`. -a| `xpack.data_enhanced.` -`search.sessions.notTouchedTimeout` {ess-icon} -| How long {kib} stores search results from unsaved sessions, +`xpack.data_enhanced.search.sessions.notTouchedTimeout` {ess-icon}:: +How long {kib} stores search results from unsaved sessions, after the last search in the session completes. The default is `5m`. -a| `xpack.data_enhanced.` -`search.sessions.notTouchedInProgressTimeout` {ess-icon} -| How long a search session can run after a user navigates away without saving a session. The default is `1m`. +`xpack.data_enhanced.search.sessions.notTouchedInProgressTimeout` {ess-icon}:: +How long a search session can run after a user navigates away without saving a session. The default is `1m`. -a| `xpack.data_enhanced.` -`search.sessions.maxUpdateRetries` {ess-icon} -| How many retries {kib} can perform while attempting to save a search session. The default is `3`. +`xpack.data_enhanced.search.sessions.maxUpdateRetries` {ess-icon}:: +How many retries {kib} can perform while attempting to save a search session. The default is `3`. -a| `xpack.data_enhanced.` -`search.sessions.defaultExpiration` {ess-icon} -| How long search session results are stored before they are deleted. +`xpack.data_enhanced.search.sessions.defaultExpiration` {ess-icon}:: +How long search session results are stored before they are deleted. Extending a search session resets the expiration by the same value. The default is `7d`. -|=== diff --git a/docs/settings/spaces-settings.asciidoc b/docs/settings/spaces-settings.asciidoc index 8504464da1dfbe..30b7beceb70ba7 100644 --- a/docs/settings/spaces-settings.asciidoc +++ b/docs/settings/spaces-settings.asciidoc @@ -12,17 +12,13 @@ roles when Security is enabled. [[spaces-settings]] ==== Spaces settings -[cols="2*<"] -|=== -| `xpack.spaces.enabled` - | deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] - Set to `true` (default) to enable Spaces in {kib}. - This setting is deprecated. Starting in 8.0, it will not be possible to disable this plugin. +`xpack.spaces.enabled`:: +deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] +Set to `true` (default) to enable Spaces in {kib}. +This setting is deprecated. Starting in 8.0, it will not be possible to disable this plugin. -| `xpack.spaces.maxSpaces` - | The maximum amount of Spaces that can be used with this instance of {kib}. Some operations - in {kib} return all spaces using a single `_search` from {es}, so this must be - set lower than the `index.max_result_window` in {es}. - Defaults to `1000`. - -|=== +`xpack.spaces.maxSpaces`:: +The maximum amount of Spaces that can be used with this instance of {kib}. Some operations +in {kib} return all spaces using a single `_search` from {es}, so this must be +set lower than the `index.max_result_window` in {es}. +Defaults to `1000`. diff --git a/docs/user/api.asciidoc b/docs/user/api.asciidoc index 12e200bb0ba270..e17d52675437e1 100644 --- a/docs/user/api.asciidoc +++ b/docs/user/api.asciidoc @@ -1,8 +1,8 @@ [[api]] = REST API -Some {kib} features are provided via a REST API, which is ideal for creating an -integration with {kib}, or automating certain aspects of configuring and +Some {kib} features are provided via a REST API, which is ideal for creating an +integration with {kib}, or automating certain aspects of configuring and deploying {kib}. [float] @@ -18,15 +18,15 @@ NOTE: The {kib} Console supports only Elasticsearch APIs. You are unable to inte [float] [[api-authentication]] === Authentication -The {kib} APIs support key- and token-based authentication. +The {kib} APIs support key- and token-based authentication. [float] [[token-api-authentication]] ==== Token-based authentication -To use token-based authentication, you use the same username and password that you use to log into Elastic. -In a given HTTP tool, and when available, you can select to use its 'Basic Authentication' option, -which is where the username and password are stored in order to be passed as part of the call. +To use token-based authentication, you use the same username and password that you use to log into Elastic. +In a given HTTP tool, and when available, you can select to use its 'Basic Authentication' option, +which is where the username and password are stored in order to be passed as part of the call. [float] [[key-authentication]] @@ -65,7 +65,7 @@ For all APIs, you must use a request header. The {kib} APIs support the `kbn-xsr * XSRF protections are disabled using the <> setting `Content-Type: application/json`:: - Applicable only when you send a payload in the API request. {kib} API requests and responses use JSON. + Applicable only when you send a payload in the API request. {kib} API requests and responses use JSON. Typically, if you include the `kbn-xsrf` header, you must also include the `Content-Type` header. Request header example: @@ -97,6 +97,6 @@ include::{kib-repo-dir}/api/actions-and-connectors.asciidoc[] include::{kib-repo-dir}/api/dashboard-api.asciidoc[] include::{kib-repo-dir}/api/logstash-configuration-management.asciidoc[] include::{kib-repo-dir}/api/machine-learning.asciidoc[] -include::{kib-repo-dir}/api/url-shortening.asciidoc[] +include::{kib-repo-dir}/api/short-urls.asciidoc[] include::{kib-repo-dir}/api/task-manager/health.asciidoc[] include::{kib-repo-dir}/api/upgrade-assistant.asciidoc[] diff --git a/package.json b/package.json index 705d902d6afef4..e9aea2d2adda31 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,6 @@ "**/pdfkit/crypto-js": "4.0.0", "**/react-syntax-highlighter": "^15.3.1", "**/react-syntax-highlighter/**/highlight.js": "^10.4.1", - "**/refractor/prismjs": "~1.25.0", "**/trim": "1.0.1", "**/typescript": "4.1.3", "**/underscore": "^1.13.1" @@ -99,11 +98,11 @@ "@elastic/apm-generator": "link:bazel-bin/packages/elastic-apm-generator", "@elastic/apm-rum": "^5.9.1", "@elastic/apm-rum-react": "^1.3.1", - "@elastic/charts": "34.2.1", + "@elastic/charts": "37.0.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.21", "@elastic/ems-client": "7.15.0", - "@elastic/eui": "38.0.1", + "@elastic/eui": "39.0.0", "@elastic/filesaver": "1.1.2", "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", @@ -184,7 +183,7 @@ "@types/redux-logger": "^3.0.8", "JSONStream": "1.3.5", "abort-controller": "^3.0.0", - "abortcontroller-polyfill": "^1.4.0", + "abortcontroller-polyfill": "^1.7.3", "angular": "^1.8.0", "angular-aria": "^1.8.0", "angular-recursion": "^1.0.5", @@ -209,7 +208,7 @@ "constate": "^1.3.2", "content-disposition": "0.5.3", "copy-to-clipboard": "^3.0.8", - "core-js": "^3.6.5", + "core-js": "^3.18.3", "cronstrue": "^1.51.0", "cytoscape": "^3.10.0", "cytoscape-dagre": "^2.2.2", @@ -369,7 +368,7 @@ "remark-stringify": "^9.0.0", "require-in-the-middle": "^5.1.0", "reselect": "^4.0.0", - "resize-observer-polyfill": "^1.5.0", + "resize-observer-polyfill": "^1.5.1", "rison-node": "1.0.2", "rxjs": "^6.5.5", "safe-squel": "^5.12.5", diff --git a/packages/kbn-apm-config-loader/src/config.test.ts b/packages/kbn-apm-config-loader/src/config.test.ts index 4e4dbf81740b34..60d773e3a420ba 100644 --- a/packages/kbn-apm-config-loader/src/config.test.ts +++ b/packages/kbn-apm-config-loader/src/config.test.ts @@ -94,8 +94,8 @@ describe('ApmConfiguration', () => { "globalLabels": Object {}, "logUncaughtExceptions": true, "metricsInterval": "30s", - "secretToken": "ZQHYvrmXEx04ozge8F", - "serverUrl": "https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io", + "secretToken": "7YKhoXsO4MzjhXjx2c", + "serverUrl": "https://kibana-ci-apm.apm.us-central1.gcp.cloud.es.io", "serviceName": "serviceName", "serviceVersion": "8.0.0", "transactionSampleRate": 1, @@ -117,8 +117,8 @@ describe('ApmConfiguration', () => { }, "logUncaughtExceptions": true, "metricsInterval": "120s", - "secretToken": "ZQHYvrmXEx04ozge8F", - "serverUrl": "https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io", + "secretToken": "7YKhoXsO4MzjhXjx2c", + "serverUrl": "https://kibana-ci-apm.apm.us-central1.gcp.cloud.es.io", "serviceName": "serviceName", "serviceVersion": "8.0.0", "transactionSampleRate": 1, diff --git a/packages/kbn-apm-config-loader/src/config.ts b/packages/kbn-apm-config-loader/src/config.ts index ad2fd63f0fec45..999e4ce3a68053 100644 --- a/packages/kbn-apm-config-loader/src/config.ts +++ b/packages/kbn-apm-config-loader/src/config.ts @@ -23,14 +23,14 @@ const DEFAULT_CONFIG: AgentConfigOptions = { }; const CENTRALIZED_SERVICE_BASE_CONFIG: AgentConfigOptions = { - serverUrl: 'https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io', + serverUrl: 'https://kibana-ci-apm.apm.us-central1.gcp.cloud.es.io', // The secretToken below is intended to be hardcoded in this file even though // it makes it public. This is not a security/privacy issue. Normally we'd // instead disable the need for a secretToken in the APM Server config where // the data is transmitted to, but due to how it's being hosted, it's easier, // for now, to simply leave it in. - secretToken: 'ZQHYvrmXEx04ozge8F', + secretToken: '7YKhoXsO4MzjhXjx2c', centralConfig: false, metricsInterval: '30s', diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts index 05f54e8d38c8cd..3dff5acdc228a4 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts @@ -130,7 +130,7 @@ export class CiStatsReporter { } try { - const { stdout } = await execa('git', ['branch', '--show-current']); + const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD']); branch = stdout; } catch (e) { this.log.debug(e.message); diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 3c0e3fa94be022..595b619a7f2a4d 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -9075,7 +9075,7 @@ class CiStatsReporter { try { const { stdout - } = await (0, _execa.default)('git', ['branch', '--show-current']); + } = await (0, _execa.default)('git', ['rev-parse', '--abbrev-ref', 'HEAD']); branch = stdout; } catch (e) { this.log.debug(e.message); diff --git a/packages/kbn-ui-shared-deps-npm/BUILD.bazel b/packages/kbn-ui-shared-deps-npm/BUILD.bazel index ae3a6f05d456bc..2ca08a34d761f5 100644 --- a/packages/kbn-ui-shared-deps-npm/BUILD.bazel +++ b/packages/kbn-ui-shared-deps-npm/BUILD.bazel @@ -30,7 +30,6 @@ NPM_MODULE_EXTRA_FILES = [ RUNTIME_DEPS = [ "//packages/elastic-datemath", - "//packages/kbn-utils", "@npm//@babel/runtime", "@npm//@elastic/charts", "@npm//@elastic/eui", @@ -61,6 +60,7 @@ RUNTIME_DEPS = [ "@npm//rxjs", "@npm//styled-components", "@npm//symbol-observable", + "@npm//tslib", "@npm//url-loader", "@npm//val-loader", "@npm//whatwg-fetch", @@ -71,8 +71,6 @@ WEBPACK_DEPS = [ ] TYPES_DEPS = [ - "//packages/elastic-datemath", - "//packages/kbn-utils", "@npm//@elastic/charts", "@npm//@elastic/eui", "@npm//@elastic/numeral", diff --git a/packages/kbn-ui-shared-deps-npm/webpack.config.js b/packages/kbn-ui-shared-deps-npm/webpack.config.js index 6c37e2e9adf9e0..c06ce6704f4514 100644 --- a/packages/kbn-ui-shared-deps-npm/webpack.config.js +++ b/packages/kbn-ui-shared-deps-npm/webpack.config.js @@ -11,13 +11,13 @@ const webpack = require('webpack'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const { REPO_ROOT } = require('@kbn/utils'); - const UiSharedDepsNpm = require('./src/index'); const MOMENT_SRC = require.resolve('moment/min/moment-with-locales.js'); const WEBPACK_SRC = require.resolve('webpack'); +const REPO_ROOT = Path.resolve(__dirname, '..', '..'); + module.exports = (_, argv) => { const outputPath = argv.outputPath ? Path.resolve(argv.outputPath) : UiSharedDepsNpm.distDir; @@ -40,7 +40,6 @@ module.exports = (_, argv) => { // modules from npm '@elastic/charts', - '@elastic/datemath', '@elastic/eui', '@elastic/eui/dist/eui_charts_theme', '@elastic/eui/lib/services', diff --git a/packages/kbn-ui-shared-deps-src/.babelrc b/packages/kbn-ui-shared-deps-src/.babelrc deleted file mode 100644 index 7da72d17791281..00000000000000 --- a/packages/kbn-ui-shared-deps-src/.babelrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "presets": ["@kbn/babel-preset/node_preset"] -} diff --git a/packages/kbn-ui-shared-deps-src/BUILD.bazel b/packages/kbn-ui-shared-deps-src/BUILD.bazel index f23c86769fb496..d1c9115c0515bb 100644 --- a/packages/kbn-ui-shared-deps-src/BUILD.bazel +++ b/packages/kbn-ui-shared-deps-src/BUILD.bazel @@ -38,7 +38,6 @@ RUNTIME_DEPS = [ "//packages/kbn-monaco", "//packages/kbn-std", "//packages/kbn-ui-shared-deps-npm", - "//packages/kbn-utils", ] TYPES_DEPS = [ @@ -50,9 +49,7 @@ TYPES_DEPS = [ "//packages/kbn-monaco", "//packages/kbn-std", "//packages/kbn-ui-shared-deps-npm", - "//packages/kbn-utils", "@npm//@elastic/eui", - "@npm//resize-observer-polyfill", "@npm//webpack", ] diff --git a/packages/kbn-ui-shared-deps-src/webpack.config.js b/packages/kbn-ui-shared-deps-src/webpack.config.js index 125a3539dd6679..ad84c9444fd0fa 100644 --- a/packages/kbn-ui-shared-deps-src/webpack.config.js +++ b/packages/kbn-ui-shared-deps-src/webpack.config.js @@ -10,13 +10,14 @@ const Path = require('path'); const webpack = require('webpack'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const { REPO_ROOT } = require('@kbn/utils'); const UiSharedDepsNpm = require('@kbn/ui-shared-deps-npm'); const UiSharedDepsSrc = require('./src'); const MOMENT_SRC = require.resolve('moment/min/moment-with-locales.js'); +const REPO_ROOT = Path.resolve(__dirname, '..', '..'); + module.exports = { node: { child_process: 'empty', diff --git a/src/core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap b/src/core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap index 16b67bfa0584f5..5b0e831286aad3 100644 --- a/src/core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap +++ b/src/core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap @@ -35,7 +35,12 @@ exports[`StatusTable renders when statuses is provided 1`] = ` }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} rowProps={[Function]} tableLayout="fixed" diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 01108298adc992..a4ca5722c6aca2 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -233,6 +233,9 @@ export class DocLinksService { networkMap: `${SECURITY_SOLUTION_DOCS}conf-map-ui.html`, troubleshootGaps: `${SECURITY_SOLUTION_DOCS}alerts-ui-monitor.html#troubleshoot-gaps`, }, + securitySolution: { + trustedApps: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/trusted-apps-ov.html`, + }, query: { eql: `${ELASTICSEARCH_DOCS}eql.html`, kueryQuerySyntax: `${KIBANA_DOCS}kuery-query.html`, @@ -641,6 +644,9 @@ export interface DocLinksStart { readonly networkMap: string; readonly troubleshootGaps: string; }; + readonly securitySolution: { + readonly trustedApps: string; + }; readonly query: { readonly eql: string; readonly kueryQuerySyntax: string; diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap index 54e223cdc5d412..d714f2159d1a23 100644 --- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap +++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap @@ -7,6 +7,7 @@ exports[`#start() returns \`Context\` component 1`] = ` Object { "mapping": Object { "euiAccordion.isLoading": "Loading", + "euiBasicTable.noItemsMessage": "No items found", "euiBasicTable.selectAllRows": "Select all rows", "euiBasicTable.selectThisRow": "Select this row", "euiBasicTable.tableAutoCaptionWithPagination": [Function], @@ -120,13 +121,15 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiLink.external.ariaLabel": "External link", "euiLink.newTarget.screenReaderOnlyText": "(opens in a new tab or window)", "euiMarkdownEditorFooter.closeButton": "Close", - "euiMarkdownEditorFooter.descriptionPrefix": "This editor uses", - "euiMarkdownEditorFooter.descriptionSuffix": "You can also utilize these additional syntax plugins to add rich content to your text.", "euiMarkdownEditorFooter.errorsTitle": "Errors", + "euiMarkdownEditorFooter.mdSyntaxLink": "GitHub flavored markdown", "euiMarkdownEditorFooter.openUploadModal": "Open upload files modal", "euiMarkdownEditorFooter.showMarkdownHelp": "Show markdown help", "euiMarkdownEditorFooter.showSyntaxErrors": "Show errors", "euiMarkdownEditorFooter.supportedFileTypes": [Function], + "euiMarkdownEditorFooter.syntaxModalDescriptionPrefix": "This editor uses", + "euiMarkdownEditorFooter.syntaxModalDescriptionSuffix": "You can also utilize these additional syntax plugins to add rich content to your text.", + "euiMarkdownEditorFooter.syntaxPopoverDescription": "This editor uses", "euiMarkdownEditorFooter.syntaxTitle": "Syntax help", "euiMarkdownEditorFooter.unsupportedFileType": "File type not supported", "euiMarkdownEditorFooter.uploadingFiles": "Click to upload files", diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index 2fe9657bce8c9a..7585ada886c059 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -68,6 +68,9 @@ export const getEuiContextMapping = (): EuiTokensObject => { values: { tableCaption }, description: 'Screen reader text to describe the pagination controls', }), + 'euiBasicTable.noItemsMessage': i18n.translate('core.euiBasicTable.noItemsMessage', { + defaultMessage: 'No items found', + }), 'euiBottomBar.customScreenReaderAnnouncement': ({ landmarkHeading }: EuiValues) => i18n.translate('core.euiBottomBar.customScreenReaderAnnouncement', { defaultMessage: @@ -634,19 +637,31 @@ export const getEuiContextMapping = (): EuiTokensObject => { defaultMessage: 'Syntax help', } ), - 'euiMarkdownEditorFooter.descriptionPrefix': i18n.translate( - 'core.euiMarkdownEditorFooter.descriptionPrefix', + 'euiMarkdownEditorFooter.mdSyntaxLink': i18n.translate( + 'core.euiMarkdownEditorFooter.mdSyntaxLink', + { + defaultMessage: 'GitHub flavored markdown', + } + ), + 'euiMarkdownEditorFooter.syntaxModalDescriptionPrefix': i18n.translate( + 'core.euiMarkdownEditorFooter.syntaxModalDescriptionPrefix', { defaultMessage: 'This editor uses', } ), - 'euiMarkdownEditorFooter.descriptionSuffix': i18n.translate( - 'core.euiMarkdownEditorFooter.descriptionSuffix', + 'euiMarkdownEditorFooter.syntaxModalDescriptionSuffix': i18n.translate( + 'core.euiMarkdownEditorFooter.syntaxModalDescriptionSuffix', { defaultMessage: 'You can also utilize these additional syntax plugins to add rich content to your text.', } ), + 'euiMarkdownEditorFooter.syntaxPopoverDescription': i18n.translate( + 'core.euiMarkdownEditorFooter.syntaxPopoverDescription', + { + defaultMessage: 'This editor uses', + } + ), 'euiMarkdownEditorToolbar.editor': i18n.translate('core.euiMarkdownEditorToolbar.editor', { defaultMessage: 'Editor', }), diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 45b7e3bdc02b5c..324066764768d0 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -614,6 +614,9 @@ export interface DocLinksStart { readonly networkMap: string; readonly troubleshootGaps: string; }; + readonly securitySolution: { + readonly trustedApps: string; + }; readonly query: { readonly eql: string; readonly kueryQuerySyntax: string; diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts index e08f2216f5cbef..99fe6c7cd1dc45 100644 --- a/src/core/server/config/deprecation/core_deprecations.test.ts +++ b/src/core/server/config/deprecation/core_deprecations.test.ts @@ -54,7 +54,7 @@ describe('core deprecations', () => { describe('rewriteBasePath', () => { it('logs a warning is server.basePath is set and server.rewriteBasePath is not', () => { - const { messages } = applyCoreDeprecations({ + const { messages, levels } = applyCoreDeprecations({ server: { basePath: 'foo', }, @@ -64,6 +64,11 @@ describe('core deprecations', () => { "You should set server.basePath along with server.rewriteBasePath. Starting in 7.0, Kibana will expect that all requests start with server.basePath rather than expecting you to rewrite the requests in your reverse proxy. Set server.rewriteBasePath to false to preserve the current behavior and silence this warning.", ] `); + expect(levels).toMatchInlineSnapshot(` + Array [ + "warning", + ] + `); }); it('does not log a warning if both server.basePath and server.rewriteBasePath are unset', () => { diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index 5adbb338b42e4b..79fb2aac60da43 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -16,6 +16,7 @@ const rewriteBasePathDeprecation: ConfigDeprecation = (settings, fromPath, addDe 'will expect that all requests start with server.basePath rather than expecting you to rewrite ' + 'the requests in your reverse proxy. Set server.rewriteBasePath to false to preserve the ' + 'current behavior and silence this warning.', + level: 'warning', correctiveActions: { manualSteps: [ `Set 'server.rewriteBasePath' in the config file, CLI flag, or environment variable (in Docker only).`, diff --git a/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts index 21e99d83443f6b..0a5864dcefac25 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts @@ -55,7 +55,8 @@ const { startES } = kbnTestServer.createTestServers({ }); let esServer: kbnTestServer.TestElasticsearchUtils; -describe('migration actions', () => { +// Failing: See https://github.com/elastic/kibana/issues/113697 +describe.skip('migration actions', () => { let client: ElasticsearchClient; beforeAll(async () => { diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index e4f1c6a2d2e014..818ea3675194ed 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -75,6 +75,6 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@7.15.0': ['Elastic License 2.0'], - '@elastic/eui@38.0.1': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@39.0.0': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/charts/public/static/utils/transform_click_event.ts b/src/plugins/charts/public/static/utils/transform_click_event.ts index 7fdd59f47988d1..d175046b20ebb2 100644 --- a/src/plugins/charts/public/static/utils/transform_click_event.ts +++ b/src/plugins/charts/public/static/utils/transform_click_event.ts @@ -9,7 +9,7 @@ import { XYChartSeriesIdentifier, GeometryValue, - XYBrushArea, + XYBrushEvent, Accessor, AccessorFn, Datum, @@ -261,7 +261,7 @@ export const getFilterFromSeriesFn = */ export const getBrushFromChartBrushEventFn = (table: Datatable, xAccessor: Accessor | AccessorFn) => - ({ x: selectedRange }: XYBrushArea): BrushTriggerEvent => { + ({ x: selectedRange }: XYBrushEvent): BrushTriggerEvent => { const [start, end] = selectedRange ?? [0, 0]; const range: [number, number] = [start, end]; const column = table.columns.findIndex(({ id }) => validateAccessorId(id, xAccessor)); diff --git a/src/plugins/custom_integrations/common/index.ts b/src/plugins/custom_integrations/common/index.ts index 9af7c4ccd46330..de2a6592465a22 100755 --- a/src/plugins/custom_integrations/common/index.ts +++ b/src/plugins/custom_integrations/common/index.ts @@ -42,9 +42,6 @@ export const INTEGRATION_CATEGORY_DISPLAY = { // Kibana added upload_file: 'Upload a file', language_client: 'Language client', - - // Internal - updates_available: 'Updates available', }; /** diff --git a/src/plugins/custom_integrations/server/index.ts b/src/plugins/custom_integrations/server/index.ts index 490627ef90f8d5..00372df501435c 100755 --- a/src/plugins/custom_integrations/server/index.ts +++ b/src/plugins/custom_integrations/server/index.ts @@ -19,7 +19,7 @@ export function plugin(initializerContext: PluginInitializerContext) { export { CustomIntegrationsPluginSetup, CustomIntegrationsPluginStart } from './types'; -export type { IntegrationCategory, IntegrationCategoryCount, CustomIntegration } from '../common'; +export type { IntegrationCategory, CustomIntegration } from '../common'; export const config = { schema: schema.object({}), diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index e6a2c41fd4ecb7..712c070e17b9f8 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -543,7 +543,6 @@ export function DashboardTopNav({ createType: title, onClick: createNewVisType(visType as VisTypeAlias), 'data-test-subj': `dashboardQuickButton${name}`, - isDarkModeEnabled: IS_DARK_THEME, }; } else { const { name, icon, title, titleInWizard } = visType as BaseVisType; @@ -553,7 +552,6 @@ export function DashboardTopNav({ createType: titleInWizard || title, onClick: createNewVisType(visType as BaseVisType), 'data-test-subj': `dashboardQuickButton${name}`, - isDarkModeEnabled: IS_DARK_THEME, }; } } diff --git a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx index 46ae4d9456d92c..8a46a16c1bf0cf 100644 --- a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx +++ b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx @@ -238,16 +238,18 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { panelPaddingSize="none" data-test-subj="dashboardEditorMenuButton" > - + {() => ( + + )} ); }; diff --git a/src/plugins/data/common/search/aggs/agg_config.ts b/src/plugins/data/common/search/aggs/agg_config.ts index 1a70a41e72dd52..4cb091787c0586 100644 --- a/src/plugins/data/common/search/aggs/agg_config.ts +++ b/src/plugins/data/common/search/aggs/agg_config.ts @@ -13,11 +13,8 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { Assign, Ensure } from '@kbn/utility-types'; import { ISearchOptions, ISearchSource } from 'src/plugins/data/public'; -import { - ExpressionAstExpression, - ExpressionAstArgument, - SerializedFieldFormat, -} from 'src/plugins/expressions/common'; +import { ExpressionAstExpression, ExpressionAstArgument } from 'src/plugins/expressions/common'; +import type { SerializedFieldFormat } from 'src/plugins/field_formats/common'; import { IAggType } from './agg_type'; import { writeParams } from './agg_params'; diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts index 48ce54bbd61bdb..ebc1705f6c01be 100644 --- a/src/plugins/data/common/search/aggs/agg_type.ts +++ b/src/plugins/data/common/search/aggs/agg_type.ts @@ -10,8 +10,9 @@ import { constant, noop, identity } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ISearchSource } from 'src/plugins/data/public'; -import { DatatableColumnType, SerializedFieldFormat } from 'src/plugins/expressions/common'; +import { DatatableColumnType } from 'src/plugins/expressions/common'; import type { RequestAdapter } from 'src/plugins/inspector/common'; +import type { SerializedFieldFormat } from 'src/plugins/field_formats/common'; import { estypes } from '@elastic/elasticsearch'; import { initParams } from './agg_params'; diff --git a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts index 2aead866c6b60e..1652f51477e647 100644 --- a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts +++ b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts @@ -9,12 +9,12 @@ /* eslint-disable max-classes-per-file */ import { i18n } from '@kbn/i18n'; -import { SerializedFieldFormat } from 'src/plugins/expressions/common/types'; import { FieldFormat, FieldFormatInstanceType, FieldFormatsContentType, IFieldFormat, + SerializedFieldFormat, } from '../../../../../field_formats/common'; import { DateRange } from '../../expressions'; import { convertDateRangeToString } from '../buckets/lib/date_range'; diff --git a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap index 9cd0687a1074d2..eae20327483967 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap @@ -884,7 +884,12 @@ exports[`Inspector Data View component should render single table without select }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } onChange={[Function]} pagination={ Object { @@ -2449,7 +2454,12 @@ exports[`Inspector Data View component should support multiple datatables 1`] = }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } onChange={[Function]} pagination={ Object { diff --git a/src/plugins/data/server/ui_settings.ts b/src/plugins/data/server/ui_settings.ts index 284a381f063dd8..19ecde71e6a194 100644 --- a/src/plugins/data/server/ui_settings.ts +++ b/src/plugins/data/server/ui_settings.ts @@ -269,6 +269,12 @@ export function getUiSettings(): Record> { target="_blank" rel="noopener">frozen indices in results if enabled. Searching through frozen indices might increase the search time.`, value: false, + deprecation: { + message: i18n.translate('data.advancedSettings.search.includeFrozenTextDeprecation', { + defaultMessage: 'This setting is deprecated and will be removed in Kibana 9.0.', + }), + docLinksKey: 'kibanaSearchSettings', + }, category: ['search'], schema: schema.boolean(), }, diff --git a/src/plugins/data_views/common/data_views/data_view.ts b/src/plugins/data_views/common/data_views/data_view.ts index 00b96cda32ad74..5768ebe635729d 100644 --- a/src/plugins/data_views/common/data_views/data_view.ts +++ b/src/plugins/data_views/common/data_views/data_view.ts @@ -19,9 +19,12 @@ import { IIndexPattern, IFieldType } from '../../common'; import { DataViewField, IIndexPatternFieldList, fieldList } from '../fields'; import { formatHitProvider } from './format_hit'; import { flattenHitWrapper } from './flatten_hit'; -import { FieldFormatsStartCommon, FieldFormat } from '../../../field_formats/common'; +import { + FieldFormatsStartCommon, + FieldFormat, + SerializedFieldFormat, +} from '../../../field_formats/common'; import { DataViewSpec, TypeMeta, SourceFilter, DataViewFieldMap } from '../types'; -import { SerializedFieldFormat } from '../../../expressions/common'; interface DataViewDeps { spec?: DataViewSpec; diff --git a/src/plugins/data_views/common/types.ts b/src/plugins/data_views/common/types.ts index 2b184bc1ef2a4a..bbc5ad374636f1 100644 --- a/src/plugins/data_views/common/types.ts +++ b/src/plugins/data_views/common/types.ts @@ -13,9 +13,8 @@ import type { SavedObject } from 'src/core/server'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; import { IFieldType } from './fields'; import { RUNTIME_FIELD_TYPES } from './constants'; -import { SerializedFieldFormat } from '../../expressions/common'; import { DataViewField } from './fields'; -import { FieldFormat } from '../../field_formats/common'; +import { FieldFormat, SerializedFieldFormat } from '../../field_formats/common'; export type FieldFormatMap = Record; diff --git a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts index f1f1d74f3af3a5..0f64a6c67741d0 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts @@ -72,6 +72,8 @@ const indexPattern = { getFieldByName: (name: string) => fields.getByName(name), timeFieldName: 'timestamp', getFormatterForField: () => ({ convert: () => 'formatted' }), + isTimeNanosBased: () => false, + popularizeField: () => {}, } as unknown as IndexPattern; indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields); diff --git a/src/plugins/discover/public/application/apps/context/context_app.test.tsx b/src/plugins/discover/public/application/apps/context/context_app.test.tsx index d54a4f8bed2470..0e50f8f714a2c9 100644 --- a/src/plugins/discover/public/application/apps/context/context_app.test.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app.test.tsx @@ -26,7 +26,6 @@ const mockNavigationPlugin = { ui: { TopNavMenu: mockTopNavMenu } }; describe('ContextApp test', () => { const defaultProps = { indexPattern: indexPatternMock, - indexPatternId: 'the-index-pattern-id', anchorId: 'mocked_anchor_id', }; diff --git a/src/plugins/discover/public/application/apps/context/context_app.tsx b/src/plugins/discover/public/application/apps/context/context_app.tsx index 070391edae71c3..9d39c93d250f24 100644 --- a/src/plugins/discover/public/application/apps/context/context_app.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app.tsx @@ -12,7 +12,7 @@ import classNames from 'classnames'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiText, EuiPageContent, EuiPage, EuiSpacer } from '@elastic/eui'; import { cloneDeep } from 'lodash'; -import { esFilters, SortDirection } from '../../../../../data/public'; +import { esFilters } from '../../../../../data/public'; import { DOC_TABLE_LEGACY, SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; import { ContextErrorMessage } from './components/context_error_message'; import { IndexPattern, IndexPatternField } from '../../../../../data/common'; @@ -31,21 +31,20 @@ const ContextAppContentMemoized = memo(ContextAppContent); export interface ContextAppProps { indexPattern: IndexPattern; - indexPatternId: string; anchorId: string; } -export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAppProps) => { +export const ContextApp = ({ indexPattern, anchorId }: ContextAppProps) => { const services = getServices(); - const { uiSettings: config, capabilities, indexPatterns, navigation, filterManager } = services; + const { uiSettings, capabilities, indexPatterns, navigation, filterManager } = services; - const isLegacy = useMemo(() => config.get(DOC_TABLE_LEGACY), [config]); - const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]); + const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); + const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); /** * Context app state */ - const { appState, setAppState } = useContextAppState({ indexPattern, services }); + const { appState, setAppState } = useContextAppState({ services }); const prevAppState = useRef(); /** @@ -54,7 +53,6 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp const { fetchedState, fetchContextRows, fetchAllRows, fetchSurroundingRows } = useContextAppFetch( { anchorId, - indexPatternId, indexPattern, appState, useNewFieldsApi, @@ -79,7 +77,6 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp prevAppState.current = cloneDeep(appState); }, [ appState, - indexPatternId, anchorId, fetchContextRows, fetchAllRows, @@ -89,7 +86,7 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp const { columns, onAddColumn, onRemoveColumn, onSetColumns } = useDataGridColumns({ capabilities, - config, + config: uiSettings, indexPattern, indexPatterns, state: appState, @@ -112,7 +109,7 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp field, values, operation, - indexPatternId + indexPattern.id! ); filterManager.addFilters(newFilters); if (indexPatterns) { @@ -120,7 +117,7 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp await popularizeField(indexPattern, fieldName, indexPatterns, capabilities); } }, - [filterManager, indexPatternId, indexPatterns, indexPattern, capabilities] + [filterManager, indexPatterns, indexPattern, capabilities] ); const TopNavMenu = navigation.ui.TopNavMenu; @@ -166,7 +163,6 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp onAddColumn={onAddColumn} onRemoveColumn={onRemoveColumn} onSetColumns={onSetColumns} - sort={appState.sort as [[string, SortDirection]]} predecessorCount={appState.predecessorCount} successorCount={appState.successorCount} setAppState={setAppState} diff --git a/src/plugins/discover/public/application/apps/context/context_app_content.tsx b/src/plugins/discover/public/application/apps/context/context_app_content.tsx index 19b6bfac2876ca..2d4d3cab250f3b 100644 --- a/src/plugins/discover/public/application/apps/context/context_app_content.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app_content.tsx @@ -22,6 +22,7 @@ import { DiscoverServices } from '../../../build_services'; import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE } from './utils/constants'; import { DocTableContext } from '../main/components/doc_table/doc_table_context'; import { EsHitRecordList } from '../../types'; +import { SortPairArr } from '../main/components/doc_table/lib/get_sort'; export interface ContextAppContentProps { columns: string[]; @@ -33,7 +34,6 @@ export interface ContextAppContentProps { predecessorCount: number; successorCount: number; rows: EsHitRecordList; - sort: [[string, SortDirection]]; predecessors: EsHitRecordList; successors: EsHitRecordList; anchorStatus: LoadingStatus; @@ -65,7 +65,6 @@ export function ContextAppContent({ predecessorCount, successorCount, rows, - sort, predecessors, successors, anchorStatus, @@ -111,6 +110,9 @@ export function ContextAppContent({ }, [setAppState] ); + const sort = useMemo(() => { + return [[indexPattern.timeFieldName!, SortDirection.desc]]; + }, [indexPattern]); return ( @@ -149,7 +151,7 @@ export function ContextAppContent({ expandedDoc={expandedDoc} isLoading={isAnchorLoading} sampleSize={0} - sort={sort} + sort={sort as SortPairArr[]} isSortEnabled={false} showTimeCol={showTimeCol} services={services} diff --git a/src/plugins/discover/public/application/apps/context/context_app_route.tsx b/src/plugins/discover/public/application/apps/context/context_app_route.tsx index 4bade3d03d9938..d124fd6cfa395e 100644 --- a/src/plugins/discover/public/application/apps/context/context_app_route.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app_route.tsx @@ -49,5 +49,5 @@ export function ContextAppRoute(props: ContextAppProps) { return ; } - return ; + return ; } diff --git a/src/plugins/discover/public/application/apps/context/services/_stubs.ts b/src/plugins/discover/public/application/apps/context/services/_stubs.ts index 7e1473b876afc7..e8d09e548c07a1 100644 --- a/src/plugins/discover/public/application/apps/context/services/_stubs.ts +++ b/src/plugins/discover/public/application/apps/context/services/_stubs.ts @@ -9,7 +9,6 @@ import sinon from 'sinon'; import moment from 'moment'; -import { IndexPatternsContract } from '../../../../../../data/public'; import { EsHitRecordList } from '../../../types'; type SortHit = { @@ -18,18 +17,6 @@ type SortHit = { sort: [number, number]; }; -export function createIndexPatternsStub() { - return { - get: sinon.spy((indexPatternId) => - Promise.resolve({ - id: indexPatternId, - isTimeNanosBased: () => false, - popularizeField: () => {}, - }) - ), - } as unknown as IndexPatternsContract; -} - /** * A stubbed search source with a `fetch` method that returns all of `_stubHits`. */ diff --git a/src/plugins/discover/public/application/apps/context/services/anchor.test.ts b/src/plugins/discover/public/application/apps/context/services/anchor.test.ts index b4a76fa45ec2f9..8886c8ab11f642 100644 --- a/src/plugins/discover/public/application/apps/context/services/anchor.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/anchor.test.ts @@ -6,30 +6,29 @@ * Side Public License, v 1. */ -import { EsQuerySortValue, SortDirection } from '../../../../../../data/public'; -import { createIndexPatternsStub, createSearchSourceStub } from './_stubs'; -import { fetchAnchorProvider, updateSearchSource } from './anchor'; +import { IndexPattern, SortDirection } from '../../../../../../data/public'; +import { createSearchSourceStub } from './_stubs'; +import { fetchAnchor, updateSearchSource } from './anchor'; import { indexPatternMock } from '../../../../__mocks__/index_pattern'; import { savedSearchMock } from '../../../../__mocks__/saved_search'; -import { EsHitRecord, EsHitRecordList } from '../../../types'; +import { EsHitRecordList } from '../../../types'; describe('context app', function () { - let fetchAnchor: ( - indexPatternId: string, - anchorId: string, - sort: EsQuerySortValue[] - ) => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any let searchSourceStub: any; + const indexPattern = { + id: 'INDEX_PATTERN_ID', + isTimeNanosBased: () => false, + popularizeField: () => {}, + } as unknown as IndexPattern; describe('function fetchAnchor', function () { beforeEach(() => { searchSourceStub = createSearchSourceStub([{ _id: 'hit1' }] as unknown as EsHitRecordList); - fetchAnchor = fetchAnchorProvider(createIndexPatternsStub(), searchSourceStub); }); it('should use the `fetch` method of the SearchSource', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -38,7 +37,7 @@ describe('context app', function () { }); it('should configure the SearchSource to not inherit from the implicit root', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -49,7 +48,7 @@ describe('context app', function () { }); it('should set the SearchSource index pattern', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -59,7 +58,7 @@ describe('context app', function () { }); it('should set the SearchSource version flag to true', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -70,7 +69,7 @@ describe('context app', function () { }); it('should set the SearchSource size to 1', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -81,7 +80,7 @@ describe('context app', function () { }); it('should set the SearchSource query to an ids query', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -103,7 +102,7 @@ describe('context app', function () { }); it('should set the SearchSource sort order', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -145,7 +144,7 @@ describe('context app', function () { it('should reject with an error when no hits were found', function () { searchSourceStub._stubHits = []; - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then( @@ -161,7 +160,7 @@ describe('context app', function () { it('should return the first hit after adding an anchor marker', function () { searchSourceStub._stubHits = [{ property1: 'value1' }, { property2: 'value2' }]; - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then((anchorDocument) => { @@ -174,16 +173,18 @@ describe('context app', function () { describe('useNewFields API', () => { beforeEach(() => { searchSourceStub = createSearchSourceStub([{ _id: 'hit1' }] as unknown as EsHitRecordList); - fetchAnchor = fetchAnchorProvider(createIndexPatternsStub(), searchSourceStub, true); }); it('should request fields if useNewFieldsApi set', function () { searchSourceStub._stubHits = [{ property1: 'value1' }, { property2: 'value2' }]; - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ - { '@timestamp': SortDirection.desc }, - { _doc: SortDirection.desc }, - ]).then(() => { + return fetchAnchor( + 'id', + indexPattern, + searchSourceStub, + [{ '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }], + true + ).then(() => { const setFieldsSpy = searchSourceStub.setField.withArgs('fields'); const removeFieldsSpy = searchSourceStub.removeField.withArgs('fieldsFromSource'); expect(setFieldsSpy.calledOnce).toBe(true); diff --git a/src/plugins/discover/public/application/apps/context/services/anchor.ts b/src/plugins/discover/public/application/apps/context/services/anchor.ts index 2d64f7526ffdd0..f262d440b8a283 100644 --- a/src/plugins/discover/public/application/apps/context/services/anchor.ts +++ b/src/plugins/discover/public/application/apps/context/services/anchor.ts @@ -6,46 +6,35 @@ * Side Public License, v 1. */ -import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { - ISearchSource, - IndexPatternsContract, - EsQuerySortValue, - IndexPattern, -} from '../../../../../../data/public'; +import { ISearchSource, EsQuerySortValue, IndexPattern } from '../../../../../../data/public'; import { EsHitRecord } from '../../../types'; -export function fetchAnchorProvider( - indexPatterns: IndexPatternsContract, +export async function fetchAnchor( + anchorId: string, + indexPattern: IndexPattern, searchSource: ISearchSource, + sort: EsQuerySortValue[], useNewFieldsApi: boolean = false -) { - return async function fetchAnchor( - indexPatternId: string, - anchorId: string, - sort: EsQuerySortValue[] - ): Promise { - const indexPattern = await indexPatterns.get(indexPatternId); - updateSearchSource(searchSource, anchorId, sort, useNewFieldsApi, indexPattern); +): Promise { + updateSearchSource(searchSource, anchorId, sort, useNewFieldsApi, indexPattern); - const response = await searchSource.fetch(); - const doc = get(response, ['hits', 'hits', 0]); + const response = await searchSource.fetch(); + const doc = response.hits?.hits?.[0]; - if (!doc) { - throw new Error( - i18n.translate('discover.context.failedToLoadAnchorDocumentErrorDescription', { - defaultMessage: 'Failed to load anchor document.', - }) - ); - } + if (!doc) { + throw new Error( + i18n.translate('discover.context.failedToLoadAnchorDocumentErrorDescription', { + defaultMessage: 'Failed to load anchor document.', + }) + ); + } - return { - ...doc, - isAnchor: true, - } as EsHitRecord; - }; + return { + ...doc, + isAnchor: true, + } as EsHitRecord; } export function updateSearchSource( diff --git a/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts b/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts index 028dec7b9fe199..9bcf6f9c90d2c8 100644 --- a/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts @@ -8,9 +8,9 @@ import moment from 'moment'; import { get, last } from 'lodash'; -import { SortDirection } from 'src/plugins/data/common'; -import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs'; -import { fetchContextProvider, SurrDocType } from './context'; +import { IndexPattern, SortDirection } from 'src/plugins/data/common'; +import { createContextSearchSourceStub } from './_stubs'; +import { fetchSurroundingDocs, SurrDocType } from './context'; import { setServices } from '../../../../kibana_services'; import { Query } from '../../../../../../data/public'; import { DiscoverServices } from '../../../../build_services'; @@ -30,9 +30,6 @@ interface Timestamp { describe('context predecessors', function () { let fetchPredecessors: ( - indexPatternId: string, - timeField: string, - sortDir: SortDirection, timeValIso: string, timeValNr: number, tieBreakerField: string, @@ -41,6 +38,12 @@ describe('context predecessors', function () { ) => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockSearchSource: any; + const indexPattern = { + id: 'INDEX_PATTERN_ID', + timeFieldName: '@timestamp', + isTimeNanosBased: () => false, + popularizeField: () => {}, + } as unknown as IndexPattern; describe('function fetchPredecessors', function () { beforeEach(() => { @@ -56,30 +59,20 @@ describe('context predecessors', function () { }, } as unknown as DiscoverServices); - fetchPredecessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size = 10 - ) => { + fetchPredecessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size = 10) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.PREDECESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, [] ); @@ -95,19 +88,12 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); + } + ); }); it('should perform multiple queries with the last being unrestricted when too few hits are returned', function () { @@ -119,33 +105,26 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 2990), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 6 - ).then((hits: EsHitRecordList) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: string) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => - get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) - ); - - expect( - intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) - ).toBe(true); - // should have started at the given time - expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); - // should have ended with a half-open interval - expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'gte']); - expect(intervals.length).toBeGreaterThan(1); - - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 6).then( + (hits: EsHitRecordList) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: string) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + expect( + intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) + ).toBe(true); + // should have started at the given time + expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have ended with a half-open interval + expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'gte']); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); + } + ); }); it('should perform multiple queries until the expected hit count is returned', function () { @@ -156,57 +135,41 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_1000, - MS_PER_DAY * 1000, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: string) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => { - return get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']); - }); - - // should have started at the given time - expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 1000).toISOString()); - // should have stopped before reaching MS_PER_DAY * 1700 - expect(moment(last(intervals)?.lte).valueOf()).toBeLessThan(MS_PER_DAY * 1700); - expect(intervals.length).toBeGreaterThan(1); - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_1000, MS_PER_DAY * 1000, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: string) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => { + return get(query, [ + 'bool', + 'must', + 'constant_score', + 'filter', + 'range', + '@timestamp', + ]); + }); + + // should have started at the given time + expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 1000).toISOString()); + // should have stopped before reaching MS_PER_DAY * 1700 + expect(moment(last(intervals)?.lte).valueOf()).toBeLessThan(MS_PER_DAY * 1700); + expect(intervals.length).toBeGreaterThan(1); + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + } + ); }); it('should return an empty array when no hits were found', function () { - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - expect(hits).toEqual([]); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + expect(hits).toEqual([]); + } + ); }); it('should configure the SearchSource to not inherit from the implicit root', function () { - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then(() => { + return fetchPredecessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then(() => { const setParentSpy = mockSearchSource.setParent; expect(setParentSpy.alwaysCalledWith(undefined)).toBe(true); expect(setParentSpy.called).toBe(true); @@ -214,16 +177,7 @@ describe('context predecessors', function () { }); it('should set the tiebreaker sort order to the opposite as the time field', function () { - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP, - MS_PER_DAY, - '_doc', - 0, - 3 - ).then(() => { + return fetchPredecessors(ANCHOR_TIMESTAMP, MS_PER_DAY, '_doc', 0, 3).then(() => { expect( mockSearchSource.setField.calledWith('sort', [ { '@timestamp': { order: 'asc', format: 'strict_date_optional_time' } }, @@ -248,32 +202,23 @@ describe('context predecessors', function () { }, } as unknown as DiscoverServices); - fetchPredecessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size = 10 - ) => { + fetchPredecessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size = 10) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub(), true).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.PREDECESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, - [] + [], + true ); }; }); @@ -287,23 +232,16 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); - const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(removeFieldsSpy.calledOnce).toBe(true); - expect(setFieldsSpy.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); + const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(removeFieldsSpy.calledOnce).toBe(true); + expect(setFieldsSpy.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); + } + ); }); }); }); diff --git a/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts b/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts index 656491f01f9cf6..169d9697536453 100644 --- a/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts @@ -8,11 +8,11 @@ import moment from 'moment'; import { get, last } from 'lodash'; -import { SortDirection } from 'src/plugins/data/common'; -import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs'; +import { IndexPattern, SortDirection } from 'src/plugins/data/common'; +import { createContextSearchSourceStub } from './_stubs'; import { setServices } from '../../../../kibana_services'; import { Query } from '../../../../../../data/public'; -import { fetchContextProvider, SurrDocType } from './context'; +import { fetchSurroundingDocs, SurrDocType } from './context'; import { DiscoverServices } from '../../../../build_services'; import { EsHitRecord, EsHitRecordList } from '../../../types'; @@ -29,9 +29,6 @@ interface Timestamp { describe('context successors', function () { let fetchSuccessors: ( - indexPatternId: string, - timeField: string, - sortDir: SortDirection, timeValIso: string, timeValNr: number, tieBreakerField: string, @@ -40,6 +37,12 @@ describe('context successors', function () { ) => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockSearchSource: any; + const indexPattern = { + id: 'INDEX_PATTERN_ID', + timeFieldName: '@timestamp', + isTimeNanosBased: () => false, + popularizeField: () => {}, + } as unknown as IndexPattern; describe('function fetchSuccessors', function () { beforeEach(() => { @@ -55,30 +58,20 @@ describe('context successors', function () { }, } as unknown as DiscoverServices); - fetchSuccessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size - ) => { + fetchSuccessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.SUCCESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, [] ); @@ -94,19 +87,12 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 2), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits) => { - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits) => { + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + } + ); }); it('should perform multiple queries with the last being unrestricted when too few hits are returned', function () { @@ -118,33 +104,26 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 2990), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 6 - ).then((hits) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: [string]) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => - get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) - ); - - expect( - intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) - ).toBe(true); - // should have started at the given time - expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); - // should have ended with a half-open interval - expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'lte']); - expect(intervals.length).toBeGreaterThan(1); - - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 6).then( + (hits) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: [string]) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + expect( + intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) + ).toBe(true); + // should have started at the given time + expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have ended with a half-open interval + expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'lte']); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + } + ); }); it('should perform multiple queries until the expected hit count is returned', function () { @@ -157,58 +136,33 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 4 - ).then((hits) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: [string]) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => - get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) - ); - - // should have started at the given time - expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); - // should have stopped before reaching MS_PER_DAY * 2200 - expect(moment(last(intervals)?.gte).valueOf()).toBeGreaterThan(MS_PER_DAY * 2200); - expect(intervals.length).toBeGreaterThan(1); - - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 4)); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 4).then( + (hits) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: [string]) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + // should have started at the given time + expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have stopped before reaching MS_PER_DAY * 2200 + expect(moment(last(intervals)?.gte).valueOf()).toBeGreaterThan(MS_PER_DAY * 2200); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 4)); + } + ); }); it('should return an empty array when no hits were found', function () { - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then((hits) => { + return fetchSuccessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then((hits) => { expect(hits).toEqual([]); }); }); it('should configure the SearchSource to not inherit from the implicit root', function () { - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then(() => { + return fetchSuccessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then(() => { const setParentSpy = mockSearchSource.setParent; expect(setParentSpy.alwaysCalledWith(undefined)).toBe(true); expect(setParentSpy.called).toBe(true); @@ -216,16 +170,7 @@ describe('context successors', function () { }); it('should set the tiebreaker sort order to the same as the time field', function () { - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP, - MS_PER_DAY, - '_doc', - 0, - 3 - ).then(() => { + return fetchSuccessors(ANCHOR_TIMESTAMP, MS_PER_DAY, '_doc', 0, 3).then(() => { expect( mockSearchSource.setField.calledWith('sort', [ { '@timestamp': { order: SortDirection.desc, format: 'strict_date_optional_time' } }, @@ -250,32 +195,23 @@ describe('context successors', function () { }, } as unknown as DiscoverServices); - fetchSuccessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size - ) => { + fetchSuccessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub(), true).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.SUCCESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, - [] + [], + true ); }; }); @@ -289,23 +225,16 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 2), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits) => { - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); - const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); - expect(removeFieldsSpy.calledOnce).toBe(true); - expect(setFieldsSpy.calledOnce).toBe(true); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits) => { + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); + const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); + expect(removeFieldsSpy.calledOnce).toBe(true); + expect(setFieldsSpy.calledOnce).toBe(true); + } + ); }); }); }); diff --git a/src/plugins/discover/public/application/apps/context/services/context.ts b/src/plugins/discover/public/application/apps/context/services/context.ts index 237de8e52e656f..b76b5ac648c22b 100644 --- a/src/plugins/discover/public/application/apps/context/services/context.ts +++ b/src/plugins/discover/public/application/apps/context/services/context.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { Filter, IndexPattern, IndexPatternsContract, SearchSource } from 'src/plugins/data/public'; +import { Filter, IndexPattern, SearchSource } from 'src/plugins/data/public'; import { reverseSortDir, SortDirection } from './utils/sorting'; import { convertIsoToMillis, extractNanos } from './utils/date_conversion'; import { fetchHitsInInterval } from './utils/fetch_hits_in_interval'; @@ -25,88 +25,82 @@ const DAY_MILLIS = 24 * 60 * 60 * 1000; // look from 1 day up to 10000 days into the past and future const LOOKUP_OFFSETS = [0, 1, 7, 30, 365, 10000].map((days) => days * DAY_MILLIS); -function fetchContextProvider(indexPatterns: IndexPatternsContract, useNewFieldsApi?: boolean) { - return { - fetchSurroundingDocs, - }; - - /** - * Fetch successor or predecessor documents of a given anchor document - * - * @param {SurrDocType} type - `successors` or `predecessors` - * @param {string} indexPatternId - * @param {EsHitRecord} anchor - anchor record - * @param {string} timeField - name of the timefield, that's sorted on - * @param {string} tieBreakerField - name of the tie breaker, the 2nd sort field - * @param {SortDirection} sortDir - direction of sorting - * @param {number} size - number of records to retrieve - * @param {Filter[]} filters - to apply in the elastic query - * @returns {Promise} - */ - async function fetchSurroundingDocs( - type: SurrDocType, - indexPatternId: string, - anchor: EsHitRecord, - timeField: string, - tieBreakerField: string, - sortDir: SortDirection, - size: number, - filters: Filter[] - ): Promise { - if (typeof anchor !== 'object' || anchor === null || !size) { - return []; - } - const indexPattern = await indexPatterns.get(indexPatternId); - const { data } = getServices(); - const searchSource = data.search.searchSource.createEmpty() as SearchSource; - updateSearchSource(searchSource, indexPattern, filters, Boolean(useNewFieldsApi)); - const sortDirToApply = type === SurrDocType.SUCCESSORS ? sortDir : reverseSortDir(sortDir); - - const nanos = indexPattern.isTimeNanosBased() ? extractNanos(anchor.fields[timeField][0]) : ''; - const timeValueMillis = - nanos !== '' ? convertIsoToMillis(anchor.fields[timeField][0]) : anchor.sort[0]; - - const intervals = generateIntervals(LOOKUP_OFFSETS, timeValueMillis as number, type, sortDir); - let documents: EsHitRecordList = []; - - for (const interval of intervals) { - const remainingSize = size - documents.length; - - if (remainingSize <= 0) { - break; - } +/** + * Fetch successor or predecessor documents of a given anchor document + * + * @param {SurrDocType} type - `successors` or `predecessors` + * @param {IndexPattern} indexPattern + * @param {EsHitRecord} anchor - anchor record + * @param {string} tieBreakerField - name of the tie breaker, the 2nd sort field + * @param {SortDirection} sortDir - direction of sorting + * @param {number} size - number of records to retrieve + * @param {Filter[]} filters - to apply in the elastic query + * @param {boolean} useNewFieldsApi + * @returns {Promise} + */ +export async function fetchSurroundingDocs( + type: SurrDocType, + indexPattern: IndexPattern, + anchor: EsHitRecord, + tieBreakerField: string, + sortDir: SortDirection, + size: number, + filters: Filter[], + useNewFieldsApi?: boolean +): Promise { + if (typeof anchor !== 'object' || anchor === null || !size) { + return []; + } + const { data } = getServices(); + const timeField = indexPattern.timeFieldName!; + const searchSource = data.search.searchSource.createEmpty() as SearchSource; + updateSearchSource(searchSource, indexPattern, filters, Boolean(useNewFieldsApi)); + const sortDirToApply = type === SurrDocType.SUCCESSORS ? sortDir : reverseSortDir(sortDir); - const searchAfter = getEsQuerySearchAfter( - type, - documents, - timeField, - anchor, - nanos, - useNewFieldsApi - ); + const nanos = indexPattern.isTimeNanosBased() ? extractNanos(anchor.fields[timeField][0]) : ''; + const timeValueMillis = + nanos !== '' ? convertIsoToMillis(anchor.fields[timeField][0]) : anchor.sort[0]; - const sort = getEsQuerySort(timeField, tieBreakerField, sortDirToApply, nanos); + const intervals = generateIntervals(LOOKUP_OFFSETS, timeValueMillis as number, type, sortDir); + let documents: EsHitRecordList = []; - const hits = await fetchHitsInInterval( - searchSource, - timeField, - sort, - sortDirToApply, - interval, - searchAfter, - remainingSize, - nanos, - anchor._id - ); + for (const interval of intervals) { + const remainingSize = size - documents.length; - documents = - type === SurrDocType.SUCCESSORS - ? [...documents, ...hits] - : [...hits.slice().reverse(), ...documents]; + if (remainingSize <= 0) { + break; } - return documents; + const searchAfter = getEsQuerySearchAfter( + type, + documents, + timeField, + anchor, + nanos, + useNewFieldsApi + ); + + const sort = getEsQuerySort(timeField, tieBreakerField, sortDirToApply, nanos); + + const hits = await fetchHitsInInterval( + searchSource, + timeField, + sort, + sortDirToApply, + interval, + searchAfter, + remainingSize, + nanos, + anchor._id + ); + + documents = + type === SurrDocType.SUCCESSORS + ? [...documents, ...hits] + : [...hits.slice().reverse(), ...documents]; } + + return documents; } export function updateSearchSource( @@ -125,5 +119,3 @@ export function updateSearchSource( .setField('filter', filters) .setField('trackTotalHits', false); } - -export { fetchContextProvider }; diff --git a/src/plugins/discover/public/application/apps/context/services/context_state.test.ts b/src/plugins/discover/public/application/apps/context/services/context_state.test.ts index 3e5acccff634e5..3df8ab710729f2 100644 --- a/src/plugins/discover/public/application/apps/context/services/context_state.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/context_state.test.ts @@ -24,7 +24,6 @@ describe('Test Discover Context State', () => { history.push('/'); state = getState({ defaultSize: 4, - timeFieldName: 'time', history, uiSettings: { get: (key: string) => @@ -44,12 +43,6 @@ describe('Test Discover Context State', () => { ], "filters": Array [], "predecessorCount": 4, - "sort": Array [ - Array [ - "time", - "desc", - ], - ], "successorCount": 4, } `); @@ -62,41 +55,29 @@ describe('Test Discover Context State', () => { state.setAppState({ predecessorCount: 10 }); state.flushToUrl(); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_a=(columns:!(_source),filters:!(),predecessorCount:10,sort:!(!(time,desc)),successorCount:4)"` + `"/#?_a=(columns:!(_source),filters:!(),predecessorCount:10,successorCount:4)"` ); }); test('getState -> url to appState syncing', async () => { - history.push( - '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' - ); + history.push('/#?_a=(columns:!(_source),predecessorCount:1,successorCount:1)'); expect(state.appState.getState()).toMatchInlineSnapshot(` Object { "columns": Array [ "_source", ], "predecessorCount": 1, - "sort": Array [ - "time", - "desc", - ], "successorCount": 1, } `); }); test('getState -> url to appState syncing with return to a url without state', async () => { - history.push( - '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' - ); + history.push('/#?_a=(columns:!(_source),predecessorCount:1,successorCount:1)'); expect(state.appState.getState()).toMatchInlineSnapshot(` Object { "columns": Array [ "_source", ], "predecessorCount": 1, - "sort": Array [ - "time", - "desc", - ], "successorCount": 1, } `); @@ -107,10 +88,6 @@ describe('Test Discover Context State', () => { "_source", ], "predecessorCount": 1, - "sort": Array [ - "time", - "desc", - ], "successorCount": 1, } `); @@ -183,7 +160,7 @@ describe('Test Discover Context State', () => { `); state.flushToUrl(); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!f,params:(query:jpg),type:phrase),query:(match_phrase:(extension:(query:jpg))))))&_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!t,params:(query:png),type:phrase),query:(match_phrase:(extension:(query:png))))),predecessorCount:4,sort:!(!(time,desc)),successorCount:4)"` + `"/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!f,params:(query:jpg),type:phrase),query:(match_phrase:(extension:(query:jpg))))))&_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!t,params:(query:png),type:phrase),query:(match_phrase:(extension:(query:png))))),predecessorCount:4,successorCount:4)"` ); }); }); diff --git a/src/plugins/discover/public/application/apps/context/services/context_state.ts b/src/plugins/discover/public/application/apps/context/services/context_state.ts index 582ca196e3484e..87f7cf00bafcf0 100644 --- a/src/plugins/discover/public/application/apps/context/services/context_state.ts +++ b/src/plugins/discover/public/application/apps/context/services/context_state.ts @@ -16,7 +16,7 @@ import { withNotifyOnErrors, ReduxLikeStateContainer, } from '../../../../../../kibana_utils/public'; -import { esFilters, FilterManager, Filter, SortDirection } from '../../../../../../data/public'; +import { esFilters, FilterManager, Filter } from '../../../../../../data/public'; import { handleSourceColumnState } from '../../../helpers/state_helpers'; export interface AppState { @@ -32,14 +32,16 @@ export interface AppState { * Number of records to be fetched before anchor records (newer records) */ predecessorCount: number; - /** - * Sorting of the records to be fetched, assumed to be a legacy parameter - */ - sort: string[][]; /** * Number of records to be fetched after the anchor records (older records) */ successorCount: number; + /** + * Array of the used sorting [[field,direction],...] + * this is actually not needed in Discover Context, there's no sorting + * but it's used in the DocTable component + */ + sort?: string[][]; } interface GlobalState { @@ -54,10 +56,6 @@ export interface GetStateParams { * Number of records to be fetched when 'Load' link/button is clicked */ defaultSize: number; - /** - * The timefield used for sorting - */ - timeFieldName: string; /** * Determins the use of long vs. short/hashed urls */ @@ -124,7 +122,6 @@ const APP_STATE_URL_KEY = '_a'; */ export function getState({ defaultSize, - timeFieldName, storeInSessionStorage = false, history, toasts, @@ -140,12 +137,7 @@ export function getState({ const globalStateContainer = createStateContainer(globalStateInitial); const appStateFromUrl = stateStorage.get(APP_STATE_URL_KEY) as AppState; - const appStateInitial = createInitialAppState( - defaultSize, - timeFieldName, - appStateFromUrl, - uiSettings - ); + const appStateInitial = createInitialAppState(defaultSize, appStateFromUrl, uiSettings); const appStateContainer = createStateContainer(appStateInitial); const { start, stop } = syncStates([ @@ -267,7 +259,6 @@ function getFilters(state: AppState | GlobalState): Filter[] { */ function createInitialAppState( defaultSize: number, - timeFieldName: string, urlState: AppState, uiSettings: IUiSettingsClient ): AppState { @@ -276,7 +267,6 @@ function createInitialAppState( filters: [], predecessorCount: defaultSize, successorCount: defaultSize, - sort: [[timeFieldName, SortDirection.desc]], }; if (typeof urlState !== 'object') { return defaultState; diff --git a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts index 5efd5e1195c5d1..b3626f9c06f105 100644 --- a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts +++ b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts @@ -8,12 +8,9 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { setServices, getServices } from '../../../../kibana_services'; -import { SortDirection } from '../../../../../../data/public'; import { createFilterManagerMock } from '../../../../../../data/public/query/filter_manager/filter_manager.mock'; import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '../../../../../common'; import { DiscoverServices } from '../../../../build_services'; -import { indexPatternMock } from '../../../../__mocks__/index_pattern'; -import { indexPatternsMock } from '../../../../__mocks__/index_patterns'; import { FailureReason, LoadingStatus } from '../services/context_query_state'; import { ContextAppFetchProps, useContextAppFetch } from './use_context_app_fetch'; import { @@ -21,6 +18,9 @@ import { mockPredecessorHits, mockSuccessorHits, } from '../__mocks__/use_context_app_fetch'; +import { indexPatternWithTimefieldMock } from '../../../../__mocks__/index_pattern_with_timefield'; +import { createContextSearchSourceStub } from '../services/_stubs'; +import { IndexPattern } from '../../../../../../data_views/common'; const mockFilterManager = createFilterManagerMock(); @@ -28,20 +28,19 @@ jest.mock('../services/context', () => { const originalModule = jest.requireActual('../services/context'); return { ...originalModule, - fetchContextProvider: () => ({ - fetchSurroundingDocs: (type: string, indexPatternId: string) => { - if (!indexPatternId) { - throw new Error(); - } - return type === 'predecessors' ? mockPredecessorHits : mockSuccessorHits; - }, - }), + + fetchSurroundingDocs: (type: string, indexPattern: IndexPattern) => { + if (!indexPattern || !indexPattern.id) { + throw new Error(); + } + return type === 'predecessors' ? mockPredecessorHits : mockSuccessorHits; + }, }; }); jest.mock('../services/anchor', () => ({ - fetchAnchorProvider: () => (indexPatternId: string) => { - if (!indexPatternId) { + fetchAnchor: (anchorId: string, indexPattern: IndexPattern) => { + if (!indexPattern.id || !anchorId) { throw new Error(); } return mockAnchorHit; @@ -50,16 +49,16 @@ jest.mock('../services/anchor', () => ({ const initDefaults = (tieBreakerFields: string[], indexPatternId = 'the-index-pattern-id') => { const dangerNotification = jest.fn(); + const mockSearchSource = createContextSearchSourceStub('timestamp'); setServices({ data: { search: { searchSource: { - createEmpty: jest.fn(), + createEmpty: jest.fn().mockImplementation(() => mockSearchSource), }, }, }, - indexPatterns: indexPatternsMock, toastNotifications: { addDanger: dangerNotification }, core: { notifications: { toasts: [] } }, history: () => {}, @@ -77,10 +76,8 @@ const initDefaults = (tieBreakerFields: string[], indexPatternId = 'the-index-pa dangerNotification, props: { anchorId: 'mock_anchor_id', - indexPatternId, - indexPattern: indexPatternMock, + indexPattern: { ...indexPatternWithTimefieldMock, id: indexPatternId }, appState: { - sort: [['order_date', SortDirection.desc]], predecessorCount: 2, successorCount: 2, }, diff --git a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx index fa6a7613973352..ed3b4e8ed5b5a8 100644 --- a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx +++ b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx @@ -7,11 +7,10 @@ */ import React, { useCallback, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { fromPairs } from 'lodash'; import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '../../../../../common'; import { DiscoverServices } from '../../../../build_services'; -import { fetchAnchorProvider } from '../services/anchor'; -import { fetchContextProvider, SurrDocType } from '../services/context'; +import { fetchAnchor } from '../services/anchor'; +import { fetchSurroundingDocs, SurrDocType } from '../services/context'; import { MarkdownSimple, toMountPoint } from '../../../../../../kibana_react/public'; import { IndexPattern, SortDirection } from '../../../../../../data/public'; import { @@ -30,7 +29,6 @@ const createError = (statusKey: string, reason: FailureReason, error?: Error) => export interface ContextAppFetchProps { anchorId: string; - indexPatternId: string; indexPattern: IndexPattern; appState: AppState; useNewFieldsApi: boolean; @@ -39,13 +37,12 @@ export interface ContextAppFetchProps { export function useContextAppFetch({ anchorId, - indexPatternId, indexPattern, appState, useNewFieldsApi, services, }: ContextAppFetchProps) { - const { uiSettings: config, data, indexPatterns, toastNotifications, filterManager } = services; + const { uiSettings: config, data, toastNotifications, filterManager } = services; const searchSource = useMemo(() => { return data.search.searchSource.createEmpty(); @@ -54,13 +51,6 @@ export function useContextAppFetch({ () => getFirstSortableField(indexPattern, config.get(CONTEXT_TIE_BREAKER_FIELDS_SETTING)), [config, indexPattern] ); - const fetchAnchor = useMemo(() => { - return fetchAnchorProvider(indexPatterns, searchSource, useNewFieldsApi); - }, [indexPatterns, searchSource, useNewFieldsApi]); - const { fetchSurroundingDocs } = useMemo( - () => fetchContextProvider(indexPatterns, useNewFieldsApi), - [indexPatterns, useNewFieldsApi] - ); const [fetchedState, setFetchedState] = useState( getInitialContextQueryState() @@ -71,8 +61,6 @@ export function useContextAppFetch({ }, []); const fetchAnchorRow = useCallback(async () => { - const { sort } = appState; - const [[, sortDir]] = sort; const errorTitle = i18n.translate('discover.context.unableToLoadAnchorDocumentDescription', { defaultMessage: 'Unable to load the anchor document', }); @@ -94,10 +82,11 @@ export function useContextAppFetch({ try { setState({ anchorStatus: { value: LoadingStatus.LOADING } }); - const anchor = await fetchAnchor(indexPatternId, anchorId, [ - fromPairs(sort), - { [tieBreakerField]: sortDir }, - ]); + const sort = [ + { [indexPattern.timeFieldName!]: SortDirection.desc }, + { [tieBreakerField]: SortDirection.desc }, + ]; + const anchor = await fetchAnchor(anchorId, indexPattern, searchSource, sort, useNewFieldsApi); setState({ anchor, anchorStatus: { value: LoadingStatus.LOADED } }); return anchor; } catch (error) { @@ -108,20 +97,18 @@ export function useContextAppFetch({ }); } }, [ - appState, tieBreakerField, setState, toastNotifications, - fetchAnchor, - indexPatternId, + indexPattern, anchorId, + searchSource, + useNewFieldsApi, ]); const fetchSurroundingRows = useCallback( async (type: SurrDocType, fetchedAnchor?: EsHitRecord) => { const filters = filterManager.getFilters(); - const { sort } = appState; - const [[sortField, sortDir]] = sort; const count = type === SurrDocType.PREDECESSORS ? appState.predecessorCount : appState.successorCount; @@ -135,13 +122,13 @@ export function useContextAppFetch({ setState({ [statusKey]: { value: LoadingStatus.LOADING } }); const rows = await fetchSurroundingDocs( type, - indexPatternId, + indexPattern, anchor as EsHitRecord, - sortField, tieBreakerField, - sortDir as SortDirection, + SortDirection.desc, count, - filters + filters, + useNewFieldsApi ); setState({ [type]: rows, [statusKey]: { value: LoadingStatus.LOADED } }); } catch (error) { @@ -158,9 +145,9 @@ export function useContextAppFetch({ fetchedState.anchor, tieBreakerField, setState, - fetchSurroundingDocs, - indexPatternId, + indexPattern, toastNotifications, + useNewFieldsApi, ] ); diff --git a/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts b/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts index 3e968b5dfb82e6..56701f17c7a63d 100644 --- a/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts +++ b/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts @@ -9,29 +9,21 @@ import { useEffect, useMemo, useState } from 'react'; import { cloneDeep } from 'lodash'; import { CONTEXT_DEFAULT_SIZE_SETTING } from '../../../../../common'; -import { IndexPattern } from '../../../../../../data/public'; import { DiscoverServices } from '../../../../build_services'; import { AppState, getState } from '../services/context_state'; -export function useContextAppState({ - indexPattern, - services, -}: { - indexPattern: IndexPattern; - services: DiscoverServices; -}) { +export function useContextAppState({ services }: { services: DiscoverServices }) { const { uiSettings: config, history, core, filterManager } = services; const stateContainer = useMemo(() => { return getState({ defaultSize: parseInt(config.get(CONTEXT_DEFAULT_SIZE_SETTING), 10), - timeFieldName: indexPattern.timeFieldName!, storeInSessionStorage: config.get('state:storeInSessionStorage'), history: history(), toasts: core.notifications.toasts, uiSettings: config, }); - }, [config, history, indexPattern, core.notifications.toasts]); + }, [config, history, core.notifications.toasts]); const [appState, setState] = useState(stateContainer.appState.getState()); diff --git a/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx b/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx index 333050e1ca5e61..350c46591c8b4a 100644 --- a/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx +++ b/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx @@ -14,6 +14,7 @@ import dateMath from '@elastic/datemath'; import { Axis, BrushEndListener, + XYBrushEvent, Chart, ElementClickListener, HistogramBarSeries, @@ -65,8 +66,8 @@ export function DiscoverHistogram({ const timeZone = getTimezone(uiSettings); const { chartData, fetchStatus } = dataState; - const onBrushEnd: BrushEndListener = useCallback( - ({ x }) => { + const onBrushEnd = useCallback( + ({ x }: XYBrushEvent) => { if (!x) { return; } @@ -184,7 +185,7 @@ export function DiscoverHistogram({ { expect(result).toMatchInlineSnapshot(` Object { "columns": Array [], - "searchSource": Object { - "index": "the-index-pattern-id", - "sort": Array [ - Object { - "_score": "desc", - }, - ], - }, + "getSearchSource": [Function], } `); }); @@ -66,14 +59,7 @@ describe('getSharingData', () => { "column_a", "column_b", ], - "searchSource": Object { - "index": "the-index-pattern-id", - "sort": Array [ - Object { - "_score": "desc", - }, - ], - }, + "getSearchSource": [Function], } `); }); @@ -108,14 +94,7 @@ describe('getSharingData', () => { "cool-field-5", "cool-field-6", ], - "searchSource": Object { - "index": "the-index-pattern-id", - "sort": Array [ - Object { - "_doc": "desc", - }, - ], - }, + "getSearchSource": [Function], } `); }); @@ -158,14 +137,7 @@ describe('getSharingData', () => { "cool-field-5", "cool-field-6", ], - "searchSource": Object { - "index": "the-index-pattern-id", - "sort": Array [ - Object { - "_doc": false, - }, - ], - }, + "getSearchSource": [Function], } `); }); diff --git a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts index 420ff0fa11eeb9..21292fabdd13f9 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts @@ -9,7 +9,7 @@ import type { Capabilities } from 'kibana/public'; import type { IUiSettingsClient } from 'src/core/public'; import type { DataPublicPluginStart } from 'src/plugins/data/public'; -import type { ISearchSource } from 'src/plugins/data/common'; +import type { ISearchSource, SearchSourceFields } from 'src/plugins/data/common'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../../../common'; import type { SavedSearch, SortOrder } from '../../../../saved_searches/types'; import { getSortForSearchSource } from '../components/doc_table'; @@ -31,8 +31,8 @@ export async function getSharingData( 'sort', getSortForSearchSource(state.sort as SortOrder[], index, config.get(SORT_DEFAULT_ORDER_SETTING)) ); - // When sharing externally we preserve relative time values - searchSource.setField('filter', data.query.timefilter.timefilter.createRelativeFilter(index)); + + searchSource.removeField('filter'); searchSource.removeField('highlight'); searchSource.removeField('highlightAll'); searchSource.removeField('aggs'); @@ -54,7 +54,15 @@ export async function getSharingData( } return { - searchSource: searchSource.getSerializedFields(true), + getSearchSource: (absoluteTime?: boolean): SearchSourceFields => { + const filter = absoluteTime + ? data.query.timefilter.timefilter.createFilter(index) + : data.query.timefilter.timefilter.createRelativeFilter(index); + + searchSource.setField('filter', filter); + + return searchSource.getSerializedFields(true); + }, columns, }; } diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.test.tsx b/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.test.tsx index 26ef7483bbbbd9..0ae82872124a43 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.test.tsx +++ b/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.test.tsx @@ -14,12 +14,6 @@ import { mountWithI18nProvider } from '@kbn/test/jest'; import { Frequency } from './types'; import { CronEditor } from './cron_editor'; -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { - return { - htmlIdGenerator: () => () => `generated-id`, - }; -}); - describe('CronEditor', () => { ['MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'].forEach((unit) => { test(`is rendered with a ${unit} frequency`, () => { diff --git a/src/plugins/expressions/common/expression_types/specs/datatable.ts b/src/plugins/expressions/common/expression_types/specs/datatable.ts index b45c36950f8708..a07f103d12e063 100644 --- a/src/plugins/expressions/common/expression_types/specs/datatable.ts +++ b/src/plugins/expressions/common/expression_types/specs/datatable.ts @@ -8,11 +8,11 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { map, pick, zipObject } from 'lodash'; +import type { SerializedFieldFormat } from 'src/plugins/field_formats/common'; import { ExpressionTypeDefinition, ExpressionValueBoxed } from '../types'; import { PointSeries, PointSeriesColumn } from './pointseries'; import { ExpressionValueRender } from './render'; -import { SerializedFieldFormat } from '../../types'; const name = 'datatable'; diff --git a/src/plugins/expressions/common/types/common.ts b/src/plugins/expressions/common/types/common.ts index 64b3d00895f564..b28ff27a79ac1c 100644 --- a/src/plugins/expressions/common/types/common.ts +++ b/src/plugins/expressions/common/types/common.ts @@ -46,14 +46,3 @@ export type TypeString = KnownTypeToString< * `date` is typed as a number or string, and represents a date */ export type UnmappedTypeStrings = 'date' | 'filter'; - -/** - * JSON representation of a field formatter configuration. - * Is used to carry information about how to format data in - * a data table as part of the column definition. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface SerializedFieldFormat> { - id?: string; - params?: TParams; -} diff --git a/src/plugins/expressions/common/types/index.ts b/src/plugins/expressions/common/types/index.ts index 7d672df2401ffa..00a79289c0b5fe 100644 --- a/src/plugins/expressions/common/types/index.ts +++ b/src/plugins/expressions/common/types/index.ts @@ -6,13 +6,7 @@ * Side Public License, v 1. */ -export { - TypeToString, - KnownTypeToString, - TypeString, - UnmappedTypeStrings, - SerializedFieldFormat, -} from './common'; +export { TypeToString, KnownTypeToString, TypeString, UnmappedTypeStrings } from './common'; export * from './style'; export * from './registry'; diff --git a/src/plugins/expressions/public/index.ts b/src/plugins/expressions/public/index.ts index 02656943cac3ed..6e8d220a3ca0ca 100644 --- a/src/plugins/expressions/public/index.ts +++ b/src/plugins/expressions/public/index.ts @@ -97,7 +97,6 @@ export { PointSeriesRow, Range, SerializedDatatable, - SerializedFieldFormat, Style, TextAlignment, TextDecoration, diff --git a/src/plugins/expressions/server/index.ts b/src/plugins/expressions/server/index.ts index c09a8bf0104af4..7c0662ad54e4b0 100644 --- a/src/plugins/expressions/server/index.ts +++ b/src/plugins/expressions/server/index.ts @@ -88,7 +88,6 @@ export { PointSeriesRow, Range, SerializedDatatable, - SerializedFieldFormat, Style, TextAlignment, TextDecoration, diff --git a/src/plugins/expressions/tsconfig.json b/src/plugins/expressions/tsconfig.json index 6716149d6b9c7e..d9991ff791e83d 100644 --- a/src/plugins/expressions/tsconfig.json +++ b/src/plugins/expressions/tsconfig.json @@ -11,5 +11,6 @@ { "path": "../../core/tsconfig.json" }, { "path": "../kibana_utils/tsconfig.json" }, { "path": "../inspector/tsconfig.json" }, + { "path": "../field_formats/tsconfig.json" } ] } diff --git a/src/plugins/home/public/application/components/tutorial/__snapshots__/content.test.js.snap b/src/plugins/home/public/application/components/tutorial/__snapshots__/content.test.js.snap index 690875dc96900a..5b9cde6e3c9a95 100644 --- a/src/plugins/home/public/application/components/tutorial/__snapshots__/content.test.js.snap +++ b/src/plugins/home/public/application/components/tutorial/__snapshots__/content.test.js.snap @@ -1,17 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`should render content with markdown 1`] = ` - + + I am *some* [content](https://en.wikipedia.org/wiki/Content) with \`markdown\` + `; diff --git a/src/plugins/home/public/application/components/tutorial/__snapshots__/footer.test.js.snap b/src/plugins/home/public/application/components/tutorial/__snapshots__/footer.test.js.snap index f054b5f5d9363d..395fe30d48aca0 100644 --- a/src/plugins/home/public/application/components/tutorial/__snapshots__/footer.test.js.snap +++ b/src/plugins/home/public/application/components/tutorial/__snapshots__/footer.test.js.snap @@ -1,35 +1,32 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`render 1`] = ` -
- - + - - -

- -

-
-
- +

+ +

+ +
+ + - - launch myapp - - -
-
+ launch myapp + + + `; diff --git a/src/plugins/home/public/application/components/tutorial/__snapshots__/instruction_set.test.js.snap b/src/plugins/home/public/application/components/tutorial/__snapshots__/instruction_set.test.js.snap index 073d20b4bf8048..5235392121ab03 100644 --- a/src/plugins/home/public/application/components/tutorial/__snapshots__/instruction_set.test.js.snap +++ b/src/plugins/home/public/application/components/tutorial/__snapshots__/instruction_set.test.js.snap @@ -1,649 +1,693 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`render 1`] = ` -
- + <_EuiSplitPanelInner + color="subdued" + paddingSize="none" > - - -

- title1 -

-
-
- -
- - - OSX - - + + Windows + + + + <_EuiSplitPanelInner + paddingSize="l" + > + - Windows - - - - , - "key": 0, - "title": "step 1", - }, - Object { - "children": , - "key": 1, - "title": "step 2", - }, - ] - } - /> -
+ + +

+ title1 +

+
+
+ + + + , + "key": 0, + "title": "step 1", + }, + Object { + "children": , + "key": 1, + "title": "step 2", + }, + ] + } + titleSize="xs" + /> + + `; exports[`statusCheckState checking status 1`] = ` -
- + <_EuiSplitPanelInner + color="subdued" + paddingSize="none" > - - -

- title1 -

-
-
- -
- - - OSX - - + + Windows + + + + <_EuiSplitPanelInner + paddingSize="l" + > + - Windows - - - - , - "key": 0, - "title": "step 1", - }, - Object { - "children": , - "key": 1, - "title": "step 2", - }, - Object { - "children": - - - - - + +

+ title1 +

+
+
+ +
+ + , + "key": 0, + "title": "step 1", + }, + Object { + "children": , + "key": 1, + "title": "step 2", + }, + Object { + "children": + + + - - custom btn label - - -
- - , - "key": "checkStatusStep", - "status": "incomplete", - "title": "custom title", - }, - ] - } - /> -
+ custom btn label + + , + "key": "checkStatusStep", + "status": "incomplete", + "title": "custom title", + }, + ] + } + titleSize="xs" + /> + + `; exports[`statusCheckState failed status check - error 1`] = ` -
- + <_EuiSplitPanelInner + color="subdued" + paddingSize="none" > - - -

- title1 -

-
-
- -
- - - OSX - - + + Windows + + + + <_EuiSplitPanelInner + paddingSize="l" + > + - Windows - - - - , - "key": 0, - "title": "step 1", - }, - Object { - "children": , - "key": 1, - "title": "step 2", - }, - Object { - "children": - - - - - + +

+ title1 +

+
+
+ +
+ + , + "key": 0, + "title": "step 1", + }, + Object { + "children": , + "key": 1, + "title": "step 2", + }, + Object { + "children": + + + - - custom btn label - - -
- - - , - "key": "checkStatusStep", - "status": "danger", - "title": "custom title", - }, - ] - } - /> -
+ custom btn label + + + + + + , + "key": "checkStatusStep", + "status": "danger", + "title": "custom title", + }, + ] + } + titleSize="xs" + /> + + `; exports[`statusCheckState failed status check - no data 1`] = ` -
- + <_EuiSplitPanelInner + color="subdued" + paddingSize="none" > - - -

- title1 -

-
-
- -
- - - OSX - - + + Windows + + + + <_EuiSplitPanelInner + paddingSize="l" + > + - Windows - - - - , - "key": 0, - "title": "step 1", - }, - Object { - "children": , - "key": 1, - "title": "step 2", - }, - Object { - "children": - - - - - + +

+ title1 +

+
+
+ +
+ + , + "key": 0, + "title": "step 1", + }, + Object { + "children": , + "key": 1, + "title": "step 2", + }, + Object { + "children": + + + - - custom btn label - - -
- - - , - "key": "checkStatusStep", - "status": "warning", - "title": "custom title", - }, - ] - } - /> -
+ custom btn label + + + + + + , + "key": "checkStatusStep", + "status": "warning", + "title": "custom title", + }, + ] + } + titleSize="xs" + /> + + `; exports[`statusCheckState initial state - no check has been attempted 1`] = ` -
- + <_EuiSplitPanelInner + color="subdued" + paddingSize="none" > - - -

- title1 -

-
-
- -
- - - OSX - - + + Windows + + + + <_EuiSplitPanelInner + paddingSize="l" + > + - Windows - - - - , - "key": 0, - "title": "step 1", - }, - Object { - "children": , - "key": 1, - "title": "step 2", - }, - Object { - "children": - - - - - + +

+ title1 +

+
+
+ +
+ + , + "key": 0, + "title": "step 1", + }, + Object { + "children": , + "key": 1, + "title": "step 2", + }, + Object { + "children": + + + - - custom btn label - - -
- - , - "key": "checkStatusStep", - "status": "incomplete", - "title": "custom title", - }, - ] - } - /> -
+ custom btn label + + , + "key": "checkStatusStep", + "status": "incomplete", + "title": "custom title", + }, + ] + } + titleSize="xs" + /> + + `; exports[`statusCheckState successful status check 1`] = ` -
- + <_EuiSplitPanelInner + color="subdued" + paddingSize="none" > - - -

- title1 -

-
-
- -
- - - OSX - - + + Windows + + + + <_EuiSplitPanelInner + paddingSize="l" + > + - Windows - - - - , - "key": 0, - "title": "step 1", - }, - Object { - "children": , - "key": 1, - "title": "step 2", - }, - Object { - "children": - - - - - + +

+ title1 +

+
+
+ +
+ + , + "key": 0, + "title": "step 1", + }, + Object { + "children": , + "key": 1, + "title": "step 2", + }, + Object { + "children": + + + - - custom btn label - - -
- - - , - "key": "checkStatusStep", - "status": "complete", - "title": "custom title", - }, - ] - } - /> -
+ custom btn label + + + + + + , + "key": "checkStatusStep", + "status": "complete", + "title": "custom title", + }, + ] + } + titleSize="xs" + /> + + `; diff --git a/src/plugins/home/public/application/components/tutorial/__snapshots__/introduction.test.js.snap b/src/plugins/home/public/application/components/tutorial/__snapshots__/introduction.test.js.snap index 410d29a42cac96..39bdda213acbab 100644 --- a/src/plugins/home/public/application/components/tutorial/__snapshots__/introduction.test.js.snap +++ b/src/plugins/home/public/application/components/tutorial/__snapshots__/introduction.test.js.snap @@ -1,182 +1,118 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`props exportedFieldsUrl 1`] = ` - - - - - + + +
+ -

- Great tutorial -

-
-
-
- -
- - - - -
-
- -
+ + + + + } + pageTitle={ + + Great tutorial + + } +/> `; exports[`props iconType 1`] = ` - - - - - - - - -

- Great tutorial -

-
-
-
- -
- -
+ + + + } + iconType="logoElastic" + pageTitle={ + + Great tutorial + + } +/> `; exports[`props isBeta 1`] = ` - - - - - -

- Great tutorial -   - -

-
-
-
- -
- -
+ + + + } + pageTitle={ + + Great tutorial + +   + + + + } +/> `; exports[`props previewUrl 1`] = ` - - - - - -

- Great tutorial -

-
-
-
- -
- - - -
+ + + + } + pageTitle={ + + Great tutorial + + } + rightSideItems={ + Array [ + , + ] + } +/> `; exports[`render 1`] = ` - - - - - -

- Great tutorial -

-
-
-
- -
- -
+ + + + } + pageTitle={ + + Great tutorial + + } +/> `; diff --git a/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap b/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap index f643f2a22ea424..d970dd5416816b 100644 --- a/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap +++ b/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap @@ -115,219 +115,124 @@ exports[`bulkCreate should display error message when bulkCreate request fails 1 ] } > - - - - -

- Imports index pattern, visualizations and pre-defined dashboards. -

-
-
- - - Load Kibana objects - - -
- - - , - "key": "installStep", - "status": "incomplete", - "title": "Load Kibana objects", - }, - ] - } + +

+ Load Kibana objects +

+
+
- +
-
- +
- - - - Step 1 is incomplete - - - - - - -

- Load Kibana objects +

+ Imports index pattern, visualizations and pre-defined dashboards.

-
-
-
+ +
+ + +
+ - -
- -
- -
-

- Imports index pattern, visualizations and pre-defined dashboards. -

-
-
-
-
- -
- - - - - -
-
-
-
- -
- - -
-
- Request failed, Error: simulated bulkRequest error + + Load Kibana objects + -
-
-
-
+ + + +
- +
- + + +
+ + +
+
+ + Request failed, Error: simulated bulkRequest error + +
+
+
`; @@ -446,260 +351,161 @@ exports[`bulkCreate should display success message when bulkCreate is successful ] } > - - - - -

- Imports index pattern, visualizations and pre-defined dashboards. -

-
-
- - - Load Kibana objects - - -
- - - , - "key": "installStep", - "status": "complete", - "title": "Load Kibana objects", - }, - ] - } + +

+ Load Kibana objects +

+
+
- +
-
- - - - - - - - +
-

- Load Kibana objects +

+ Imports index pattern, visualizations and pre-defined dashboards.

- -
-
+ +
+ + +
+ - -
- -
- -
-

- Imports index pattern, visualizations and pre-defined dashboards. -

-
-
-
-
- -
- - - - - -
-
-
-
- -
- - -
-
- 1 saved objects successfully added + + Load Kibana objects + -
-
-
-
+ + + +
- +
+
+ + +
+ + +
+
+ + 1 saved objects successfully added + +
- +
`; exports[`renders 1`] = ` - - - - -

- Imports index pattern, visualizations and pre-defined dashboards. -

-
-
- - - Load Kibana objects - - -
- - , - "key": "installStep", - "status": "incomplete", - "title": "Load Kibana objects", - }, - ] - } -/> + + +

+ Load Kibana objects +

+
+ + + +

+ Imports index pattern, visualizations and pre-defined dashboards. +

+
+
+ + + Load Kibana objects + + +
+ +
`; diff --git a/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap b/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap index ac697fae17f693..91dcdabd75deed 100644 --- a/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap +++ b/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap @@ -1,173 +1,146 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`isCloudEnabled is false should not render instruction toggle when ON_PREM_ELASTIC_CLOUD instructions are not provided 1`] = ` - - -
- - -
- - - + + + - -
- - + ], + }, + ] + } + isCloudEnabled={false} + offset={1} + onStatusCheck={[Function]} + paramValues={Object {}} + replaceTemplateStrings={[Function]} + setParameter={[Function]} + statusCheckState="NOT_CHECKED" + title="Instruction title" + /> +
+ `; exports[`isCloudEnabled is false should render ON_PREM instructions with instruction toggle 1`] = ` - - -
- + + + + - -
- - - - - -
- - - + + - -
-
-
+ ], + }, + ] + } + isCloudEnabled={false} + offset={1} + onStatusCheck={[Function]} + paramValues={Object {}} + replaceTemplateStrings={[Function]} + setParameter={[Function]} + statusCheckState="NOT_CHECKED" + title="Instruction title" + /> +
+ `; exports[`should render ELASTIC_CLOUD instructions when isCloudEnabled is true 1`] = ` - - -
- - -
- - - + + + - -
- - + ], + }, + ] + } + isCloudEnabled={true} + offset={1} + onStatusCheck={[Function]} + paramValues={Object {}} + replaceTemplateStrings={[Function]} + setParameter={[Function]} + statusCheckState="NOT_CHECKED" + title="Instruction title" + /> +
+ `; diff --git a/src/plugins/home/public/application/components/tutorial/_tutorial.scss b/src/plugins/home/public/application/components/tutorial/_tutorial.scss index b517476885e2ed..6d6ca4781346d1 100644 --- a/src/plugins/home/public/application/components/tutorial/_tutorial.scss +++ b/src/plugins/home/public/application/components/tutorial/_tutorial.scss @@ -1,7 +1,3 @@ -.homTutorial__notFoundPanel { - background: $euiColorEmptyShade; - padding: $euiSizeL; -} .homTutorial__instruction { flex-shrink: 0; diff --git a/src/plugins/home/public/application/components/tutorial/content.js b/src/plugins/home/public/application/components/tutorial/content.js index 8b0e09d2eb8517..d076957521eee1 100644 --- a/src/plugins/home/public/application/components/tutorial/content.js +++ b/src/plugins/home/public/application/components/tutorial/content.js @@ -8,19 +8,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Markdown } from '../../../../../kibana_react/public'; - -const whiteListedRules = ['backticks', 'emphasis', 'link', 'list']; +import { EuiMarkdownFormat } from '@elastic/eui'; export function Content({ text }) { - return ( - - ); + return {text}; } Content.propTypes = { diff --git a/src/plugins/home/public/application/components/tutorial/content.test.js b/src/plugins/home/public/application/components/tutorial/content.test.js index e0b0a256f207c0..f8a75d0a04f1c6 100644 --- a/src/plugins/home/public/application/components/tutorial/content.test.js +++ b/src/plugins/home/public/application/components/tutorial/content.test.js @@ -11,12 +11,6 @@ import { shallow } from 'enzyme'; import { Content } from './content'; -jest.mock('../../../../../kibana_react/public', () => { - return { - Markdown: () =>
, - }; -}); - test('should render content with markdown', () => { const component = shallow( - + + + +

+ +

+
+
- - - -

- -

-
-
- - - - {label} - - -
-
+ + + {label} + + + ); } diff --git a/src/plugins/home/public/application/components/tutorial/instruction.js b/src/plugins/home/public/application/components/tutorial/instruction.js index e4b3b3f321bf9e..ebe78b78f300d7 100644 --- a/src/plugins/home/public/application/components/tutorial/instruction.js +++ b/src/plugins/home/public/application/components/tutorial/instruction.js @@ -10,18 +10,7 @@ import React, { Suspense, useMemo } from 'react'; import PropTypes from 'prop-types'; import { Content } from './content'; -import { - EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiCopy, - EuiButton, - EuiLoadingSpinner, - EuiErrorBoundary, -} from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCodeBlock, EuiSpacer, EuiLoadingSpinner, EuiErrorBoundary } from '@elastic/eui'; import { getServices } from '../../kibana_services'; @@ -39,16 +28,21 @@ export function Instruction({ let pre; if (textPre) { - pre = ; + pre = ( + <> + + + + ); } let post; if (textPost) { post = ( -
+ <> -
+ ); } const customComponent = tutorialService.getCustomComponent(customComponentName); @@ -59,7 +53,6 @@ export function Instruction({ } }, [customComponent]); - let copyButton; let commandBlock; if (commands) { const cmdText = commands @@ -67,35 +60,16 @@ export function Instruction({ return replaceTemplateStrings(cmd, paramValues); }) .join('\n'); - copyButton = ( - - {(copy) => ( - - - - )} - - ); commandBlock = ( -
- - {cmdText} -
+ + {cmdText} + ); } return (
- - {pre} - - - {copyButton} - - + {pre} {commandBlock} @@ -114,8 +88,6 @@ export function Instruction({ )} {post} - -
); } diff --git a/src/plugins/home/public/application/components/tutorial/instruction_set.js b/src/plugins/home/public/application/components/tutorial/instruction_set.js index 08b55a527b3cf5..822c60cdc31bad 100644 --- a/src/plugins/home/public/application/components/tutorial/instruction_set.js +++ b/src/plugins/home/public/application/components/tutorial/instruction_set.js @@ -21,12 +21,13 @@ import { EuiFlexItem, EuiButton, EuiCallOut, - EuiButtonEmpty, EuiTitle, + EuiSplitPanel, } from '@elastic/eui'; import * as StatusCheckStates from './status_check_states'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { euiThemeVars } from '@kbn/ui-shared-deps-src/theme'; class InstructionSetUi extends React.Component { constructor(props) { @@ -97,7 +98,12 @@ class InstructionSetUi extends React.Component { color = 'warning'; break; } - return ; + return ( + <> + + + + ); } getStepStatus(statusCheckState) { @@ -131,27 +137,20 @@ class InstructionSetUi extends React.Component { const { statusCheckState, statusCheckConfig, onStatusCheck } = this.props; const checkStatusStep = ( - - - - - - - - {statusCheckConfig.btnLabel || ( - - )} - - - + + + {statusCheckConfig.btnLabel || ( + + )} + {this.renderStatusCheckMessage()} @@ -202,27 +201,29 @@ class InstructionSetUi extends React.Component { steps.push(this.renderStatusCheck()); } - return ; + return ( + <> + + + + ); }; renderHeader = () => { let paramsVisibilityToggle; if (this.props.params) { - const ariaLabel = this.props.intl.formatMessage({ - id: 'home.tutorial.instructionSet.toggleAriaLabel', - defaultMessage: 'toggle command parameters visibility', - }); paramsVisibilityToggle = ( - - + ); } @@ -245,11 +246,14 @@ class InstructionSetUi extends React.Component { } return ( - + <> + + + ); }; @@ -257,28 +261,29 @@ class InstructionSetUi extends React.Component { let paramsForm; if (this.props.params && this.state.isParamFormVisible) { paramsForm = ( - + <> + + + ); } return ( -
- {this.renderHeader()} - - {this.renderCallOut()} - - {paramsForm} - - {this.renderTabs()} - - - - {this.renderInstructions()} -
+ + + {this.renderTabs()} + + + {this.renderHeader()} + {paramsForm} + {this.renderCallOut()} + {this.renderInstructions()} + + ); } } diff --git a/src/plugins/home/public/application/components/tutorial/instruction_set.test.js b/src/plugins/home/public/application/components/tutorial/instruction_set.test.js index 1bce4f72fde607..6faadf275bea3d 100644 --- a/src/plugins/home/public/application/components/tutorial/instruction_set.test.js +++ b/src/plugins/home/public/application/components/tutorial/instruction_set.test.js @@ -34,12 +34,6 @@ const instructionVariants = [ }, ]; -jest.mock('../../../../../kibana_react/public', () => { - return { - Markdown: () =>
, - }; -}); - test('render', () => { const component = shallowWithIntl( - ); + />, + ]; } let exportedFields; if (exportedFieldsUrl) { exportedFields = ( -
- - + <> +
+ -
-
- ); - } - let icon; - if (iconType) { - icon = ( - - - + + ); } let betaBadge; @@ -81,31 +64,28 @@ function IntroductionUI({ ); } return ( - - - - {icon} - - -

- {title} - {betaBadge && ( - <> -   - {betaBadge} - - )} -

-
-
-
- - - {exportedFields} -
- - {img} -
+ + {title} + {betaBadge && ( + <> +   + {betaBadge} + + )} + + } + description={ + <> + + {exportedFields} + {notices} + + } + rightSideItems={rightSideItems} + /> ); } @@ -116,6 +96,7 @@ IntroductionUI.propTypes = { exportedFieldsUrl: PropTypes.string, iconType: PropTypes.string, isBeta: PropTypes.bool, + notices: PropTypes.node, }; IntroductionUI.defaultProps = { diff --git a/src/plugins/home/public/application/components/tutorial/introduction.test.js b/src/plugins/home/public/application/components/tutorial/introduction.test.js index a0ab9d8c8e6a73..949f84d0181ed7 100644 --- a/src/plugins/home/public/application/components/tutorial/introduction.test.js +++ b/src/plugins/home/public/application/components/tutorial/introduction.test.js @@ -11,12 +11,6 @@ import { shallowWithIntl } from '@kbn/test/jest'; import { Introduction } from './introduction'; -jest.mock('../../../../../kibana_react/public', () => { - return { - Markdown: () =>
, - }; -}); - test('render', () => { const component = shallowWithIntl( { + render() { const installMsg = this.props.installMsg ? this.props.installMsg : this.props.intl.formatMessage({ id: 'home.tutorial.savedObject.installLabel', defaultMessage: 'Imports index pattern, visualizations and pre-defined dashboards.', }); - const installStep = ( - + + return ( + <> + +

+ {this.props.intl.formatMessage({ + id: 'home.tutorial.savedObject.loadTitle', + defaultMessage: 'Load Kibana objects', + })} +

+
@@ -190,22 +199,8 @@ Click 'Confirm overwrite' to import and overwrite existing objects. Any changes {this.renderInstallMessage()} -
+ ); - - return { - title: this.props.intl.formatMessage({ - id: 'home.tutorial.savedObject.loadTitle', - defaultMessage: 'Load Kibana objects', - }), - status: this.state.isInstalled ? 'complete' : 'incomplete', - children: installStep, - key: 'installStep', - }; - }; - - render() { - return ; } } diff --git a/src/plugins/home/public/application/components/tutorial/tutorial.js b/src/plugins/home/public/application/components/tutorial/tutorial.js index 52daa53d4585c8..508a236bf45d4d 100644 --- a/src/plugins/home/public/application/components/tutorial/tutorial.js +++ b/src/plugins/home/public/application/components/tutorial/tutorial.js @@ -7,26 +7,18 @@ */ import _ from 'lodash'; -import React from 'react'; +import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { Footer } from './footer'; import { Introduction } from './introduction'; import { InstructionSet } from './instruction_set'; import { SavedObjectsInstaller } from './saved_objects_installer'; -import { - EuiSpacer, - EuiPage, - EuiPanel, - EuiText, - EuiPageBody, - EuiButtonGroup, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; +import { EuiSpacer, EuiPanel, EuiButton, EuiButtonGroup, EuiFormRow } from '@elastic/eui'; import * as StatusCheckStates from './status_check_states'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { getServices } from '../../kibana_services'; +import { KibanaPageTemplate } from '../../../../../kibana_react/public'; const INSTRUCTIONS_TYPE = { ELASTIC_CLOUD: 'elasticCloud', @@ -250,19 +242,22 @@ class TutorialUi extends React.Component { }, ]; return ( - - + <> + + - - + + ); } }; @@ -286,23 +281,25 @@ class TutorialUi extends React.Component { offset += instructionSet.instructionVariants[0].instructions.length; return ( - { - this.onStatusCheck(index); - }} - offset={currentOffset} - params={instructions.params} - paramValues={this.state.paramValues} - setParameter={this.setParameter} - replaceTemplateStrings={this.props.replaceTemplateStrings} - key={index} - isCloudEnabled={this.props.isCloudEnabled} - /> + + { + this.onStatusCheck(index); + }} + offset={currentOffset} + params={instructions.params} + paramValues={this.state.paramValues} + setParameter={this.setParameter} + replaceTemplateStrings={this.props.replaceTemplateStrings} + isCloudEnabled={this.props.isCloudEnabled} + /> + {index < instructions.instructionSets.length - 1 && } + ); }); }; @@ -313,11 +310,16 @@ class TutorialUi extends React.Component { } return ( - + <> + + + + + ); }; @@ -338,22 +340,23 @@ class TutorialUi extends React.Component { } if (url && label) { - return
; + return ( + <> + + +
+ + + ); } }; renderModuleNotices() { const notices = getServices().tutorialService.getModuleNotices(); if (notices.length && this.state.tutorial.moduleName) { - return ( - - {notices.map((ModuleNotice, index) => ( - - - - ))} - - ); + return notices.map((ModuleNotice, index) => ( + + )); } else { return null; } @@ -363,17 +366,34 @@ class TutorialUi extends React.Component { let content; if (this.state.notFound) { content = ( -
- -

+ -

-
-
+ ), + rightSideItems: [ + + {i18n.translate('home.tutorial.backToDirectory', { + defaultMessage: 'Back to directory', + })} + , + ], + }} + /> ); } @@ -405,27 +425,20 @@ class TutorialUi extends React.Component { exportedFieldsUrl={exportedFieldsUrl} iconType={icon} isBeta={this.state.tutorial.isBeta} + notices={this.renderModuleNotices()} /> - {this.renderModuleNotices()} - -
{this.renderInstructionSetsToggle()}
+ {this.renderInstructionSetsToggle()} - - {this.renderInstructionSets(instructions)} - {this.renderSavedObjectsInstaller()} - {this.renderFooter()} - + {this.renderInstructionSets(instructions)} + {this.renderSavedObjectsInstaller()} + {this.renderFooter()}
); } - return ( - - {content} - - ); + return {content}; } } diff --git a/src/plugins/home/public/application/components/tutorial/tutorial.test.js b/src/plugins/home/public/application/components/tutorial/tutorial.test.js index c76b20e63ae95f..c68f5ec69e1610 100644 --- a/src/plugins/home/public/application/components/tutorial/tutorial.test.js +++ b/src/plugins/home/public/application/components/tutorial/tutorial.test.js @@ -33,11 +33,6 @@ jest.mock('../../kibana_services', () => ({ }, }), })); -jest.mock('../../../../../kibana_react/public', () => { - return { - Markdown: () =>
, - }; -}); function buildInstructionSet(type) { return { diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx index e291ec7b4ca083..d33a0d2a87fb5d 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx @@ -9,12 +9,6 @@ import React from 'react'; const EDITOR_ID = 'testEditor'; -jest.mock('@elastic/eui/lib/services/accessibility', () => { - return { - htmlIdGenerator: () => () => `generated-id`, - }; -}); - jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/color/__snapshots__/color.test.tsx.snap b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/color/__snapshots__/color.test.tsx.snap index 7406e5ae9bb2da..1cff82729e6f99 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/color/__snapshots__/color.test.tsx.snap +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/color/__snapshots__/color.test.tsx.snap @@ -76,7 +76,12 @@ exports[`ColorFormatEditor should render multiple colors 1`] = ` }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} tableLayout="fixed" /> @@ -170,7 +175,12 @@ exports[`ColorFormatEditor should render other type normally (range field) 1`] = }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} tableLayout="fixed" /> @@ -264,7 +274,12 @@ exports[`ColorFormatEditor should render string type normally (regex field) 1`] }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} tableLayout="fixed" /> diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.tsx.snap b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.tsx.snap index 664912789b0e35..3b476a6037aed6 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.tsx.snap +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.tsx.snap @@ -55,7 +55,12 @@ exports[`StaticLookupFormatEditor should render multiple lookup entries and unkn }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} style={ Object { @@ -159,7 +164,12 @@ exports[`StaticLookupFormatEditor should render normally 1`] = ` }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} style={ Object { diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap index 79c1a11cfef84a..3890d6c2b9ddbe 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap @@ -292,7 +292,7 @@ exports[`UrlFormatEditor should render normally 1`] = `
{ - return { - htmlIdGenerator: () => () => `generated-id`, - }; -}); - const fieldType = 'string'; const format = { getConverterFor: jest diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/samples/__snapshots__/samples.test.tsx.snap b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/samples/__snapshots__/samples.test.tsx.snap index 1a0b96c14fe359..71693a1e5cb8c8 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/samples/__snapshots__/samples.test.tsx.snap +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/samples/__snapshots__/samples.test.tsx.snap @@ -52,7 +52,12 @@ exports[`FormatEditorSamples should render normally 1`] = ` }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} tableLayout="fixed" /> diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx index 33850005b498be..fea6bf41a16018 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx @@ -18,13 +18,17 @@ type AllowedPopoverProps = Omit< 'button' | 'isOpen' | 'closePopover' | 'anchorPosition' >; -export type Props = AllowedButtonProps & AllowedPopoverProps; +export type Props = AllowedButtonProps & + AllowedPopoverProps & { + children: (arg: { closePopover: () => void }) => React.ReactNode; + }; export const SolutionToolbarPopover = ({ label, iconType, primary, iconSide, + children, ...popover }: Props) => { const [isOpen, setIsOpen] = useState(false); @@ -33,10 +37,21 @@ export const SolutionToolbarPopover = ({ const closePopover = () => setIsOpen(false); const button = ( - + ); return ( - + + {children({ closePopover })} + ); }; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss index 876ee659b71d72..535570a51d777c 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss @@ -8,17 +8,4 @@ border-color: $euiBorderColor !important; } } - - // Temporary fix for two tone icons to make them monochrome - .quickButtonGroup__button--dark { - .euiIcon path { - fill: $euiColorGhost; - } - } - // Temporary fix for two tone icons to make them monochrome - .quickButtonGroup__button--light { - .euiIcon path { - fill: $euiColorInk; - } - } } diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx index eb0a395548cd90..66b22eeb570dbf 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx @@ -17,27 +17,23 @@ import './quick_group.scss'; export interface QuickButtonProps extends Pick { createType: string; onClick: () => void; - isDarkModeEnabled?: boolean; } export interface Props { buttons: QuickButtonProps[]; } -type Option = EuiButtonGroupOptionProps & - Omit; +type Option = EuiButtonGroupOptionProps & Omit; export const QuickButtonGroup = ({ buttons }: Props) => { const buttonGroupOptions: Option[] = buttons.map((button: QuickButtonProps, index) => { - const { createType: label, isDarkModeEnabled, ...rest } = button; + const { createType: label, ...rest } = button; const title = strings.getAriaButtonLabel(label); return { ...rest, 'aria-label': title, - className: `quickButtonGroup__button ${ - isDarkModeEnabled ? 'quickButtonGroup__button--dark' : 'quickButtonGroup__button--light' - }`, + className: `quickButtonGroup__button`, id: `${htmlIdGenerator()()}${index}`, label, title, diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx index fa33f53f9ae4f8..3a04a4c974538b 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx @@ -54,29 +54,31 @@ const primaryButtonConfigs = { panelPaddingSize="none" primary={true} > - + {() => ( + + )} ), Dashboard: ( @@ -93,29 +95,31 @@ const extraButtonConfigs = { Canvas: undefined, Dashboard: [ - + {() => ( + + )} , ], }; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index 8325e7dc886e80..bca54ff67591ce 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -188,7 +188,12 @@ exports[`Table prevents saved objects from being deleted 1`] = ` ] } loading={false} - noItemsMessage="No items found" + noItemsMessage={ + + } onChange={[Function]} pagination={ Object { @@ -403,7 +408,12 @@ exports[`Table should render normally 1`] = ` ] } loading={false} - noItemsMessage="No items found" + noItemsMessage={ + + } onChange={[Function]} pagination={ Object { diff --git a/src/plugins/share/common/url_service/__tests__/setup.ts b/src/plugins/share/common/url_service/__tests__/setup.ts index 239b2554e663a5..8f339c2060faff 100644 --- a/src/plugins/share/common/url_service/__tests__/setup.ts +++ b/src/plugins/share/common/url_service/__tests__/setup.ts @@ -37,7 +37,7 @@ export const urlServiceTestSetup = (partialDeps: Partial getUrl: async () => { throw new Error('not implemented'); }, - shortUrls: { + shortUrls: () => ({ get: () => ({ create: async () => { throw new Error('Not implemented.'); @@ -52,7 +52,7 @@ export const urlServiceTestSetup = (partialDeps: Partial throw new Error('Not implemented.'); }, }), - }, + }), ...partialDeps, }; const service = new UrlService(deps); diff --git a/src/plugins/share/common/url_service/locators/locator.ts b/src/plugins/share/common/url_service/locators/locator.ts index fc970e2c7a4906..2d33f701df5953 100644 --- a/src/plugins/share/common/url_service/locators/locator.ts +++ b/src/plugins/share/common/url_service/locators/locator.ts @@ -67,13 +67,15 @@ export class Locator

implements LocatorPublic

{ state: P, references: SavedObjectReference[] ): P => { - return this.definition.inject ? this.definition.inject(state, references) : state; + if (!this.definition.inject) return state; + return this.definition.inject(state, references); }; public readonly extract: PersistableState

['extract'] = ( state: P ): { state: P; references: SavedObjectReference[] } => { - return this.definition.extract ? this.definition.extract(state) : { state, references: [] }; + if (!this.definition.extract) return { state, references: [] }; + return this.definition.extract(state); }; // LocatorPublic

---------------------------------------------------------- diff --git a/src/plugins/share/common/url_service/locators/locator_client.ts b/src/plugins/share/common/url_service/locators/locator_client.ts index 587083551aa6dd..7dd69165be5dd2 100644 --- a/src/plugins/share/common/url_service/locators/locator_client.ts +++ b/src/plugins/share/common/url_service/locators/locator_client.ts @@ -7,9 +7,12 @@ */ import type { SerializableRecord } from '@kbn/utility-types'; +import { MigrateFunctionsObject } from 'src/plugins/kibana_utils/common'; +import { SavedObjectReference } from 'kibana/server'; import type { LocatorDependencies } from './locator'; -import type { LocatorDefinition, LocatorPublic, ILocatorClient } from './types'; +import type { LocatorDefinition, LocatorPublic, ILocatorClient, LocatorData } from './types'; import { Locator } from './locator'; +import { LocatorMigrationFunction, LocatorsMigrationMap } from '.'; export type LocatorClientDependencies = LocatorDependencies; @@ -44,4 +47,91 @@ export class LocatorClient implements ILocatorClient { public get

(id: string): undefined | LocatorPublic

{ return this.locators.get(id); } + + protected getOrThrow

(id: string): LocatorPublic

{ + const locator = this.locators.get(id); + if (!locator) throw new Error(`Locator [ID = "${id}"] is not registered.`); + return locator; + } + + public migrations(): { [locatorId: string]: MigrateFunctionsObject } { + const migrations: { [locatorId: string]: MigrateFunctionsObject } = {}; + + for (const locator of this.locators.values()) { + migrations[locator.id] = locator.migrations; + } + + return migrations; + } + + // PersistableStateService ---------------------------------------------------------- + + public telemetry( + state: LocatorData, + collector: Record + ): Record { + for (const locator of this.locators.values()) { + collector = locator.telemetry(state.state, collector); + } + + return collector; + } + + public inject(state: LocatorData, references: SavedObjectReference[]): LocatorData { + const locator = this.getOrThrow(state.id); + const filteredReferences = references + .filter((ref) => ref.name.startsWith('params:')) + .map((ref) => ({ + ...ref, + name: ref.name.substr('params:'.length), + })); + return { + ...state, + state: locator.inject(state.state, filteredReferences), + }; + } + + public extract(state: LocatorData): { state: LocatorData; references: SavedObjectReference[] } { + const locator = this.getOrThrow(state.id); + const extracted = locator.extract(state.state); + return { + state: { + ...state, + state: extracted.state, + }, + references: extracted.references.map((ref) => ({ + ...ref, + name: 'params:' + ref.name, + })), + }; + } + + public readonly getAllMigrations = (): LocatorsMigrationMap => { + const locatorParamsMigrations = this.migrations(); + const locatorMigrations: LocatorsMigrationMap = {}; + const versions = new Set(); + + for (const migrationMap of Object.values(locatorParamsMigrations)) + for (const version of Object.keys(migrationMap)) versions.add(version); + + for (const version of versions.values()) { + const migration: LocatorMigrationFunction = (locator) => { + const locatorMigrationsMap = locatorParamsMigrations[locator.id]; + if (!locatorMigrationsMap) return locator; + + const migrationFunction = locatorMigrationsMap[version]; + if (!migrationFunction) return locator; + + return { + ...locator, + version, + state: migrationFunction(locator.state), + }; + }; + + locatorMigrations[version] = migration; + } + + return locatorMigrations; + }; } diff --git a/src/plugins/share/common/url_service/locators/types.ts b/src/plugins/share/common/url_service/locators/types.ts index ab0efa9b2375a9..c64dc588aaf22c 100644 --- a/src/plugins/share/common/url_service/locators/types.ts +++ b/src/plugins/share/common/url_service/locators/types.ts @@ -8,13 +8,18 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { DependencyList } from 'react'; -import { PersistableState } from 'src/plugins/kibana_utils/common'; +import { + MigrateFunction, + PersistableState, + PersistableStateService, + VersionedState, +} from 'src/plugins/kibana_utils/common'; import type { FormatSearchParamsOptions } from './redirect'; /** * URL locator registry. */ -export interface ILocatorClient { +export interface ILocatorClient extends PersistableStateService { /** * Create and register a new locator. * @@ -141,3 +146,22 @@ export interface KibanaLocation { */ state: S; } + +/** + * Represents a serializable state of a locator. Includes locator ID, version + * and its params. + */ +export interface LocatorData + extends VersionedState, + SerializableRecord { + /** + * Locator ID. + */ + id: string; +} + +export interface LocatorsMigrationMap { + [semver: string]: LocatorMigrationFunction; +} + +export type LocatorMigrationFunction = MigrateFunction; diff --git a/src/plugins/share/common/url_service/mocks.ts b/src/plugins/share/common/url_service/mocks.ts index dd86e2398589ed..24ba226818427a 100644 --- a/src/plugins/share/common/url_service/mocks.ts +++ b/src/plugins/share/common/url_service/mocks.ts @@ -18,7 +18,7 @@ export class MockUrlService extends UrlService { getUrl: async ({ app, path }, { absolute }) => { return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`; }, - shortUrls: { + shortUrls: () => ({ get: () => ({ create: async () => { throw new Error('Not implemented.'); @@ -33,7 +33,7 @@ export class MockUrlService extends UrlService { throw new Error('Not implemented.'); }, }), - }, + }), }); } } diff --git a/src/plugins/share/common/url_service/short_urls/types.ts b/src/plugins/share/common/url_service/short_urls/types.ts index db744a25f9f798..698ffe7b8421b9 100644 --- a/src/plugins/share/common/url_service/short_urls/types.ts +++ b/src/plugins/share/common/url_service/short_urls/types.ts @@ -6,9 +6,8 @@ * Side Public License, v 1. */ -import { SerializableRecord } from '@kbn/utility-types'; -import { VersionedState } from 'src/plugins/kibana_utils/common'; -import { LocatorPublic } from '../locators'; +import type { SerializableRecord } from '@kbn/utility-types'; +import type { LocatorPublic, ILocatorClient, LocatorData } from '../locators'; /** * A factory for Short URL Service. We need this factory as the dependency @@ -21,6 +20,10 @@ export interface IShortUrlClientFactory { get(dependencies: D): IShortUrlClient; } +export type IShortUrlClientFactoryProvider = (params: { + locators: ILocatorClient; +}) => IShortUrlClientFactory; + /** * CRUD-like API for short URLs. */ @@ -128,14 +131,4 @@ export interface ShortUrlData; } -/** - * Represents a serializable state of a locator. Includes locator ID, version - * and its params. - */ -export interface LocatorData - extends VersionedState { - /** - * Locator ID. - */ - id: string; -} +export type { LocatorData }; diff --git a/src/plugins/share/common/url_service/url_service.ts b/src/plugins/share/common/url_service/url_service.ts index dedb81720865db..24e2ea0b623794 100644 --- a/src/plugins/share/common/url_service/url_service.ts +++ b/src/plugins/share/common/url_service/url_service.ts @@ -7,10 +7,10 @@ */ import { LocatorClient, LocatorClientDependencies } from './locators'; -import { IShortUrlClientFactory } from './short_urls'; +import { IShortUrlClientFactoryProvider, IShortUrlClientFactory } from './short_urls'; export interface UrlServiceDependencies extends LocatorClientDependencies { - shortUrls: IShortUrlClientFactory; + shortUrls: IShortUrlClientFactoryProvider; } /** @@ -26,6 +26,8 @@ export class UrlService { constructor(protected readonly deps: UrlServiceDependencies) { this.locators = new LocatorClient(deps); - this.shortUrls = deps.shortUrls; + this.shortUrls = deps.shortUrls({ + locators: this.locators, + }); } } diff --git a/src/plugins/share/public/mocks.ts b/src/plugins/share/public/mocks.ts index 73df7257290f09..33cdf141de9f37 100644 --- a/src/plugins/share/public/mocks.ts +++ b/src/plugins/share/public/mocks.ts @@ -18,7 +18,7 @@ const url = new UrlService({ getUrl: async ({ app, path }, { absolute }) => { return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`; }, - shortUrls: { + shortUrls: () => ({ get: () => ({ create: async () => { throw new Error('Not implemented'); @@ -33,7 +33,7 @@ const url = new UrlService({ throw new Error('Not implemented.'); }, }), - }, + }), }); const createSetupContract = (): Setup => { diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 103fbb50bb95f5..fd8a5fd7541a68 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -104,7 +104,7 @@ export class SharePlugin implements Plugin { }); return url; }, - shortUrls: { + shortUrls: () => ({ get: () => ({ create: async () => { throw new Error('Not implemented'); @@ -119,7 +119,7 @@ export class SharePlugin implements Plugin { throw new Error('Not implemented.'); }, }), - }, + }), }); this.url.locators.create(new LegacyShortUrlLocatorDefinition()); diff --git a/src/plugins/share/server/plugin.ts b/src/plugins/share/server/plugin.ts index f0e4abf9eb589b..d79588420fe871 100644 --- a/src/plugins/share/server/plugin.ts +++ b/src/plugins/share/server/plugin.ts @@ -9,11 +9,14 @@ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import { CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server'; -import { url } from './saved_objects'; import { CSV_SEPARATOR_SETTING, CSV_QUOTE_VALUES_SETTING } from '../common/constants'; import { UrlService } from '../common/url_service'; -import { ServerUrlService, ServerShortUrlClientFactory } from './url_service'; -import { registerUrlServiceRoutes } from './url_service/http/register_url_service_routes'; +import { + ServerUrlService, + ServerShortUrlClientFactory, + registerUrlServiceRoutes, + registerUrlServiceSavedObjectType, +} from './url_service'; import { LegacyShortUrlLocatorDefinition } from '../common/url_service/locators/legacy_short_url_locator'; /** @public */ @@ -44,18 +47,17 @@ export class SharePlugin implements Plugin { getUrl: async () => { throw new Error('Locator .getUrl() currently is not supported on the server.'); }, - shortUrls: new ServerShortUrlClientFactory({ - currentVersion: this.version, - }), + shortUrls: ({ locators }) => + new ServerShortUrlClientFactory({ + currentVersion: this.version, + locators, + }), }); - this.url.locators.create(new LegacyShortUrlLocatorDefinition()); - const router = core.http.createRouter(); - - registerUrlServiceRoutes(core, router, this.url); + registerUrlServiceSavedObjectType(core.savedObjects, this.url); + registerUrlServiceRoutes(core, core.http.createRouter(), this.url); - core.savedObjects.registerType(url); core.uiSettings.register({ [CSV_SEPARATOR_SETTING]: { name: i18n.translate('share.advancedSettings.csv.separatorTitle', { diff --git a/src/plugins/share/server/saved_objects/index.ts b/src/plugins/share/server/saved_objects/index.ts deleted file mode 100644 index ff37efb9fec178..00000000000000 --- a/src/plugins/share/server/saved_objects/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { url } from './url'; diff --git a/src/plugins/share/server/saved_objects/url.ts b/src/plugins/share/server/saved_objects/url.ts deleted file mode 100644 index 6288e87f629f53..00000000000000 --- a/src/plugins/share/server/saved_objects/url.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { SavedObjectsType } from 'kibana/server'; - -export const url: SavedObjectsType = { - name: 'url', - namespaceType: 'single', - hidden: false, - management: { - icon: 'link', - defaultSearchField: 'url', - importableAndExportable: true, - getTitle(obj) { - return `/goto/${encodeURIComponent(obj.id)}`; - }, - getInAppUrl(obj) { - return { - path: '/goto/' + encodeURIComponent(obj.id), - uiCapabilitiesPath: '', - }; - }, - }, - mappings: { - properties: { - slug: { - type: 'text', - fields: { - keyword: { - type: 'keyword', - }, - }, - }, - accessCount: { - type: 'long', - }, - accessDate: { - type: 'date', - }, - createDate: { - type: 'date', - }, - // Legacy field - contains already pre-formatted final URL. - // This is here to support old saved objects that have this field. - // TODO: Remove this field and execute a migration to the new format. - url: { - type: 'text', - fields: { - keyword: { - type: 'keyword', - ignore_above: 2048, - }, - }, - }, - // Information needed to load and execute a locator. - locatorJSON: { - type: 'text', - index: false, - }, - }, - }, -}; diff --git a/src/plugins/share/server/url_service/index.ts b/src/plugins/share/server/url_service/index.ts index 068a5289d42edd..62d13293717368 100644 --- a/src/plugins/share/server/url_service/index.ts +++ b/src/plugins/share/server/url_service/index.ts @@ -8,3 +8,5 @@ export * from './types'; export * from './short_urls'; +export { registerUrlServiceRoutes } from './http/register_url_service_routes'; +export { registerUrlServiceSavedObjectType } from './saved_objects/register_url_service_saved_object_type'; diff --git a/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.test.ts b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.test.ts new file mode 100644 index 00000000000000..651169f6101a96 --- /dev/null +++ b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SerializableRecord } from '@kbn/utility-types'; +import type { + SavedObjectMigrationMap, + SavedObjectsType, + SavedObjectUnsanitizedDoc, +} from 'kibana/server'; +import { ServerShortUrlClientFactory } from '..'; +import { UrlService, LocatorDefinition } from '../../../common/url_service'; +import { LegacyShortUrlLocatorDefinition } from '../../../common/url_service/locators/legacy_short_url_locator'; +import { MemoryShortUrlStorage } from '../short_urls/storage/memory_short_url_storage'; +import { ShortUrlSavedObjectAttributes } from '../short_urls/storage/saved_object_short_url_storage'; +import { registerUrlServiceSavedObjectType } from './register_url_service_saved_object_type'; + +const setup = () => { + const currentVersion = '7.7.7'; + const service = new UrlService({ + getUrl: () => { + throw new Error('Not implemented.'); + }, + navigate: () => { + throw new Error('Not implemented.'); + }, + shortUrls: ({ locators }) => + new ServerShortUrlClientFactory({ + currentVersion, + locators, + }), + }); + const definition = new LegacyShortUrlLocatorDefinition(); + const locator = service.locators.create(definition); + const storage = new MemoryShortUrlStorage(); + const client = service.shortUrls.get({ storage }); + + let type: SavedObjectsType; + registerUrlServiceSavedObjectType( + { + registerType: (urlSavedObjectType) => { + type = urlSavedObjectType; + }, + }, + service + ); + + return { + type: type!, + client, + service, + storage, + locator, + definition, + currentVersion, + }; +}; + +describe('migrations', () => { + test('returns empty migrations object if there are no migrations', () => { + const { type } = setup(); + + expect((type.migrations as () => SavedObjectMigrationMap)()).toEqual({}); + }); + + test('migrates locator to the latest version', () => { + interface FooLocatorParamsOld extends SerializableRecord { + color: string; + indexPattern: string; + } + + interface FooLocatorParams extends SerializableRecord { + color: string; + indexPatterns: string[]; + } + + class FooLocatorDefinition implements LocatorDefinition { + public readonly id = 'FOO_LOCATOR'; + + public async getLocation() { + return { + app: 'foo', + path: '', + state: {}, + }; + } + + migrations = { + '8.0.0': ({ indexPattern, ...rest }: FooLocatorParamsOld): FooLocatorParams => ({ + ...rest, + indexPatterns: [indexPattern], + }), + }; + } + + const { type, service } = setup(); + + service.locators.create(new FooLocatorDefinition()); + + const migrationFunction = (type.migrations as () => SavedObjectMigrationMap)()['8.0.0']; + + expect(typeof migrationFunction).toBe('function'); + + const doc1: SavedObjectUnsanitizedDoc = { + id: 'foo', + attributes: { + accessCount: 0, + accessDate: 0, + createDate: 0, + locatorJSON: JSON.stringify({ + id: 'FOO_LOCATOR', + version: '7.7.7', + state: { + color: 'red', + indexPattern: 'myIndex', + }, + }), + url: '', + }, + type: 'url', + }; + + const doc2 = migrationFunction(doc1, {} as any); + + expect(doc2.id).toBe('foo'); + expect(doc2.type).toBe('url'); + expect(doc2.attributes.accessCount).toBe(0); + expect(doc2.attributes.accessDate).toBe(0); + expect(doc2.attributes.createDate).toBe(0); + expect(doc2.attributes.url).toBe(''); + expect(JSON.parse(doc2.attributes.locatorJSON)).toEqual({ + id: 'FOO_LOCATOR', + version: '8.0.0', + state: { + color: 'red', + indexPatterns: ['myIndex'], + }, + }); + }); +}); diff --git a/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.ts b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.ts new file mode 100644 index 00000000000000..b2fcefcc767cfc --- /dev/null +++ b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + SavedObjectMigrationMap, + SavedObjectsServiceSetup, + SavedObjectsType, +} from 'kibana/server'; +import type { LocatorData } from 'src/plugins/share/common/url_service'; +import type { ServerUrlService } from '..'; + +export const registerUrlServiceSavedObjectType = ( + so: Pick, + service: ServerUrlService +) => { + const urlSavedObjectType: SavedObjectsType = { + name: 'url', + namespaceType: 'single', + hidden: false, + management: { + icon: 'link', + defaultSearchField: 'url', + importableAndExportable: true, + getTitle(obj) { + return `/goto/${encodeURIComponent(obj.id)}`; + }, + getInAppUrl(obj) { + return { + path: '/goto/' + encodeURIComponent(obj.id), + uiCapabilitiesPath: '', + }; + }, + }, + mappings: { + properties: { + slug: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + }, + }, + }, + accessCount: { + type: 'long', + }, + accessDate: { + type: 'date', + }, + createDate: { + type: 'date', + }, + // Legacy field - contains already pre-formatted final URL. + // This is here to support old saved objects that have this field. + // TODO: Remove this field and execute a migration to the new format. + url: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 2048, + }, + }, + }, + // Information needed to load and execute a locator. + locatorJSON: { + type: 'text', + index: false, + }, + }, + }, + migrations: () => { + const locatorMigrations = service.locators.getAllMigrations(); + const savedObjectLocatorMigrations: SavedObjectMigrationMap = {}; + + for (const [version, locatorMigration] of Object.entries(locatorMigrations)) { + savedObjectLocatorMigrations[version] = (doc) => { + const locator = JSON.parse(doc.attributes.locatorJSON) as LocatorData; + doc.attributes = { + ...doc.attributes, + locatorJSON: JSON.stringify(locatorMigration(locator)), + }; + return doc; + }; + } + + return savedObjectLocatorMigrations; + }, + }; + + so.registerType(urlSavedObjectType); +}; diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts index ac684eb03a9d51..503748a2b1cad5 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts @@ -7,9 +7,11 @@ */ import { ServerShortUrlClientFactory } from './short_url_client_factory'; -import { UrlService } from '../../../common/url_service'; +import { UrlService, LocatorDefinition } from '../../../common/url_service'; import { LegacyShortUrlLocatorDefinition } from '../../../common/url_service/locators/legacy_short_url_locator'; import { MemoryShortUrlStorage } from './storage/memory_short_url_storage'; +import { SerializableRecord } from '@kbn/utility-types'; +import { SavedObjectReference } from 'kibana/server'; const setup = () => { const currentVersion = '1.2.3'; @@ -20,9 +22,11 @@ const setup = () => { navigate: () => { throw new Error('Not implemented.'); }, - shortUrls: new ServerShortUrlClientFactory({ - currentVersion, - }), + shortUrls: ({ locators }) => + new ServerShortUrlClientFactory({ + currentVersion, + locators, + }), }); const definition = new LegacyShortUrlLocatorDefinition(); const locator = service.locators.create(definition); @@ -177,4 +181,111 @@ describe('ServerShortUrlClient', () => { ); }); }); + + describe('Persistable State', () => { + interface FooLocatorParams extends SerializableRecord { + dashboardId: string; + indexPatternId: string; + } + + class FooLocatorDefinition implements LocatorDefinition { + public readonly id = 'FOO_LOCATOR'; + + public readonly getLocation = async () => ({ + app: 'foo_app', + path: '/foo/path', + state: {}, + }); + + public readonly extract = ( + state: FooLocatorParams + ): { state: FooLocatorParams; references: SavedObjectReference[] } => ({ + state, + references: [ + { + id: state.dashboardId, + type: 'dashboard', + name: 'dashboardId', + }, + { + id: state.indexPatternId, + type: 'index_pattern', + name: 'indexPatternId', + }, + ], + }); + + public readonly inject = ( + state: FooLocatorParams, + references: SavedObjectReference[] + ): FooLocatorParams => { + const dashboard = references.find( + (ref) => ref.type === 'dashboard' && ref.name === 'dashboardId' + ); + const indexPattern = references.find( + (ref) => ref.type === 'index_pattern' && ref.name === 'indexPatternId' + ); + + return { + ...state, + dashboardId: dashboard ? dashboard.id : '', + indexPatternId: indexPattern ? indexPattern.id : '', + }; + }; + } + + test('extracts and persists references', async () => { + const { service, client, storage } = setup(); + const locator = service.locators.create(new FooLocatorDefinition()); + const shortUrl = await client.create({ + locator, + params: { + dashboardId: '123', + indexPatternId: '456', + }, + }); + const record = await storage.getById(shortUrl.data.id); + + expect(record.references).toEqual([ + { + id: '123', + type: 'dashboard', + name: 'locator:params:dashboardId', + }, + { + id: '456', + type: 'index_pattern', + name: 'locator:params:indexPatternId', + }, + ]); + }); + + test('injects references', async () => { + const { service, client, storage } = setup(); + const locator = service.locators.create(new FooLocatorDefinition()); + const shortUrl1 = await client.create({ + locator, + params: { + dashboardId: '3', + indexPatternId: '5', + }, + }); + const record1 = await storage.getById(shortUrl1.data.id); + + record1.data.locator.state = {}; + + await storage.update(record1.data.id, record1.data); + + const record2 = await storage.getById(shortUrl1.data.id); + + expect(record2.data.locator.state).toEqual({}); + + const shortUrl2 = await client.get(shortUrl1.data.id); + + expect(shortUrl2.data.locator.state).toEqual({ + dashboardId: '3', + indexPatternId: '5', + }); + }); + }); }); diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.ts index caaa76bef172d8..1efece073d955d 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.ts @@ -7,8 +7,17 @@ */ import type { SerializableRecord } from '@kbn/utility-types'; +import { SavedObjectReference } from 'kibana/server'; import { generateSlug } from 'random-word-slugs'; -import type { IShortUrlClient, ShortUrl, ShortUrlCreateParams } from '../../../common/url_service'; +import { ShortUrlRecord } from '.'; +import type { + IShortUrlClient, + ShortUrl, + ShortUrlCreateParams, + ILocatorClient, + ShortUrlData, + LocatorData, +} from '../../../common/url_service'; import type { ShortUrlStorage } from './types'; import { validateSlug } from './util'; @@ -36,6 +45,11 @@ export interface ServerShortUrlClientDependencies { * Storage provider for short URLs. */ storage: ShortUrlStorage; + + /** + * The locators service. + */ + locators: ILocatorClient; } export class ServerShortUrlClient implements IShortUrlClient { @@ -64,44 +78,80 @@ export class ServerShortUrlClient implements IShortUrlClient { } } + const extracted = this.extractReferences({ + id: locator.id, + version: currentVersion, + state: params, + }); const now = Date.now(); - const data = await storage.create({ - accessCount: 0, - accessDate: now, - createDate: now, - slug, - locator: { - id: locator.id, - version: currentVersion, - state: params, + + const data = await storage.create

( + { + accessCount: 0, + accessDate: now, + createDate: now, + slug, + locator: extracted.state as LocatorData

, }, - }); + { references: extracted.references } + ); return { data, }; } - public async get(id: string): Promise { - const { storage } = this.dependencies; - const data = await storage.getById(id); + private extractReferences(locatorData: LocatorData): { + state: LocatorData; + references: SavedObjectReference[]; + } { + const { locators } = this.dependencies; + const { state, references } = locators.extract(locatorData); + return { + state, + references: references.map((ref) => ({ + ...ref, + name: 'locator:' + ref.name, + })), + }; + } + private injectReferences({ data, references }: ShortUrlRecord): ShortUrlData { + const { locators } = this.dependencies; + const locatorReferences = references + .filter((ref) => ref.name.startsWith('locator:')) + .map((ref) => ({ + ...ref, + name: ref.name.substr('locator:'.length), + })); return { - data, + ...data, + locator: locators.inject(data.locator, locatorReferences), }; } - public async delete(id: string): Promise { + public async get(id: string): Promise { const { storage } = this.dependencies; - await storage.delete(id); + const record = await storage.getById(id); + const data = this.injectReferences(record); + + return { + data, + }; } public async resolve(slug: string): Promise { const { storage } = this.dependencies; - const data = await storage.getBySlug(slug); + const record = await storage.getBySlug(slug); + const data = this.injectReferences(record); return { data, }; } + + public async delete(id: string): Promise { + const { storage } = this.dependencies; + await storage.delete(id); + } } diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts b/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts index 696233b7a1ca5e..63456c36daa68f 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts @@ -8,7 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { ShortUrlStorage } from './types'; -import type { IShortUrlClientFactory } from '../../../common/url_service'; +import type { IShortUrlClientFactory, ILocatorClient } from '../../../common/url_service'; import { ServerShortUrlClient } from './short_url_client'; import { SavedObjectShortUrlStorage } from './storage/saved_object_short_url_storage'; @@ -20,6 +20,11 @@ export interface ServerShortUrlClientFactoryDependencies { * Current version of Kibana, e.g. 7.15.0. */ currentVersion: string; + + /** + * Locators service. + */ + locators: ILocatorClient; } export interface ServerShortUrlClientFactoryCreateParams { @@ -39,9 +44,11 @@ export class ServerShortUrlClientFactory savedObjects: params.savedObjects!, savedObjectType: 'url', }); + const { currentVersion, locators } = this.dependencies; const client = new ServerShortUrlClient({ storage, - currentVersion: this.dependencies.currentVersion, + currentVersion, + locators, }); return client; diff --git a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts index d178e0b81786cd..5d1b0bfa0bf550 100644 --- a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts +++ b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts @@ -41,6 +41,46 @@ describe('.create()', () => { }); }); +describe('.update()', () => { + test('can update an existing short URL', async () => { + const storage = new MemoryShortUrlStorage(); + const now = Date.now(); + const url1 = await storage.create({ + accessCount: 0, + createDate: now, + accessDate: now, + locator: { + id: 'TEST_LOCATOR', + version: '7.11', + state: { + foo: 'bar', + }, + }, + slug: 'test-slug', + }); + + await storage.update(url1.id, { + accessCount: 1, + }); + + const url2 = await storage.getById(url1.id); + + expect(url1.accessCount).toBe(0); + expect(url2.data.accessCount).toBe(1); + }); + + test('throws when URL does not exist', async () => { + const storage = new MemoryShortUrlStorage(); + const [, error] = await of( + storage.update('DOES_NOT_EXIST', { + accessCount: 1, + }) + ); + + expect(error).toBeInstanceOf(Error); + }); +}); + describe('.getById()', () => { test('can fetch by ID a newly created short URL', async () => { const storage = new MemoryShortUrlStorage(); @@ -58,7 +98,7 @@ describe('.getById()', () => { }, slug: 'test-slug', }); - const url2 = await storage.getById(url1.id); + const url2 = (await storage.getById(url1.id)).data; expect(url2.accessCount).toBe(0); expect(url1.createDate).toBe(now); @@ -112,7 +152,7 @@ describe('.getBySlug()', () => { }, slug: 'test-slug', }); - const url2 = await storage.getBySlug('test-slug'); + const url2 = (await storage.getBySlug('test-slug')).data; expect(url2.accessCount).toBe(0); expect(url1.createDate).toBe(now); diff --git a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts index 40d76a91154ba7..fafd00344eecde 100644 --- a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts +++ b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts @@ -9,35 +9,54 @@ import { v4 as uuidv4 } from 'uuid'; import type { SerializableRecord } from '@kbn/utility-types'; import { ShortUrlData } from 'src/plugins/share/common/url_service/short_urls/types'; -import { ShortUrlStorage } from '../types'; +import { SavedObjectReference } from 'kibana/server'; +import { ShortUrlStorage, ShortUrlRecord } from '../types'; + +const clone =

(obj: P): P => JSON.parse(JSON.stringify(obj)) as P; export class MemoryShortUrlStorage implements ShortUrlStorage { - private urls = new Map(); + private urls = new Map(); public async create

( - data: Omit, 'id'> + data: Omit, 'id'>, + { references = [] }: { references?: SavedObjectReference[] } = {} ): Promise> { const id = uuidv4(); - const url: ShortUrlData

= { ...data, id }; + const url: ShortUrlRecord

= { + data: { ...data, id }, + references, + }; this.urls.set(id, url); - return url; + + return clone(url.data); + } + + public async update

( + id: string, + data: Partial, 'id'>>, + { references }: { references?: SavedObjectReference[] } = {} + ): Promise { + const so = await this.getById(id); + Object.assign(so.data, data); + if (references) so.references = references; + this.urls.set(id, so); } public async getById

( id: string - ): Promise> { + ): Promise> { if (!this.urls.has(id)) { throw new Error(`No short url with id "${id}"`); } - return this.urls.get(id)! as ShortUrlData

; + return clone(this.urls.get(id)! as ShortUrlRecord

); } public async getBySlug

( slug: string - ): Promise> { + ): Promise> { for (const url of this.urls.values()) { - if (url.slug === slug) { - return url as ShortUrlData

; + if (url.data.slug === slug) { + return clone(url as ShortUrlRecord

); } } throw new Error(`No short url with slug "${slug}".`); @@ -45,7 +64,7 @@ export class MemoryShortUrlStorage implements ShortUrlStorage { public async exists(slug: string): Promise { for (const url of this.urls.values()) { - if (url.slug === slug) { + if (url.data.slug === slug) { return true; } } diff --git a/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts b/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts index c66db6d82cdbd3..792dfabde3cab3 100644 --- a/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts +++ b/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts @@ -7,7 +7,8 @@ */ import type { SerializableRecord } from '@kbn/utility-types'; -import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; +import { SavedObject, SavedObjectReference, SavedObjectsClientContract } from 'kibana/server'; +import { ShortUrlRecord } from '..'; import { LEGACY_SHORT_URL_LOCATOR_ID } from '../../../../common/url_service/locators/legacy_short_url_locator'; import { ShortUrlData } from '../../../../common/url_service/short_urls/types'; import { ShortUrlStorage } from '../types'; @@ -85,12 +86,15 @@ const createShortUrlData =

( }; const createAttributes =

( - data: Omit, 'id'> + data: Partial, 'id'>> ): ShortUrlSavedObjectAttributes => { - const { locator, ...rest } = data; + const { accessCount = 0, accessDate = 0, createDate = 0, slug = '', locator } = data; const attributes: ShortUrlSavedObjectAttributes = { - ...rest, - locatorJSON: JSON.stringify(locator), + accessCount, + accessDate, + createDate, + slug, + locatorJSON: locator ? JSON.stringify(locator) : '', url: '', }; @@ -106,30 +110,49 @@ export class SavedObjectShortUrlStorage implements ShortUrlStorage { constructor(private readonly dependencies: SavedObjectShortUrlStorageDependencies) {} public async create

( - data: Omit, 'id'> + data: Omit, 'id'>, + { references }: { references?: SavedObjectReference[] } = {} ): Promise> { const { savedObjects, savedObjectType } = this.dependencies; const attributes = createAttributes(data); const savedObject = await savedObjects.create(savedObjectType, attributes, { refresh: true, + references, }); return createShortUrlData

(savedObject); } + public async update

( + id: string, + data: Partial, 'id'>>, + { references }: { references?: SavedObjectReference[] } = {} + ): Promise { + const { savedObjects, savedObjectType } = this.dependencies; + const attributes = createAttributes(data); + + await savedObjects.update(savedObjectType, id, attributes, { + refresh: true, + references, + }); + } + public async getById

( id: string - ): Promise> { + ): Promise> { const { savedObjects, savedObjectType } = this.dependencies; const savedObject = await savedObjects.get(savedObjectType, id); - return createShortUrlData

(savedObject); + return { + data: createShortUrlData

(savedObject), + references: savedObject.references, + }; } public async getBySlug

( slug: string - ): Promise> { + ): Promise> { const { savedObjects } = this.dependencies; const search = `(attributes.slug:"${escapeSearchReservedChars(slug)}")`; const result = await savedObjects.find({ @@ -143,7 +166,10 @@ export class SavedObjectShortUrlStorage implements ShortUrlStorage { const savedObject = result.saved_objects[0] as ShortUrlSavedObject; - return createShortUrlData

(savedObject); + return { + data: createShortUrlData

(savedObject), + references: savedObject.references, + }; } public async exists(slug: string): Promise { diff --git a/src/plugins/share/server/url_service/short_urls/types.ts b/src/plugins/share/server/url_service/short_urls/types.ts index 7aab70ca495193..9a9d9006eb3715 100644 --- a/src/plugins/share/server/url_service/short_urls/types.ts +++ b/src/plugins/share/server/url_service/short_urls/types.ts @@ -7,6 +7,7 @@ */ import type { SerializableRecord } from '@kbn/utility-types'; +import { SavedObjectReference } from 'kibana/server'; import { ShortUrlData } from '../../../common/url_service/short_urls/types'; /** @@ -17,20 +18,32 @@ export interface ShortUrlStorage { * Create and store a new short URL entry. */ create

( - data: Omit, 'id'> + data: Omit, 'id'>, + options?: { references?: SavedObjectReference[] } ): Promise>; + /** + * Update an existing short URL entry. + */ + update

( + id: string, + data: Partial, 'id'>>, + options?: { references?: SavedObjectReference[] } + ): Promise; + /** * Fetch a short URL entry by ID. */ - getById

(id: string): Promise>; + getById

( + id: string + ): Promise>; /** * Fetch a short URL entry by slug. */ getBySlug

( slug: string - ): Promise>; + ): Promise>; /** * Checks if a short URL exists by slug. @@ -42,3 +55,8 @@ export interface ShortUrlStorage { */ delete(id: string): Promise; } + +export interface ShortUrlRecord { + data: ShortUrlData; + references: SavedObjectReference[]; +} diff --git a/src/plugins/vis_types/pie/public/types/types.ts b/src/plugins/vis_types/pie/public/types/types.ts index a1f41e80fae28c..fb5efb59718057 100644 --- a/src/plugins/vis_types/pie/public/types/types.ts +++ b/src/plugins/vis_types/pie/public/types/types.ts @@ -8,7 +8,8 @@ import { Position } from '@elastic/charts'; import { UiCounterMetricType } from '@kbn/analytics'; -import { DatatableColumn, SerializedFieldFormat } from '../../../../expressions/public'; +import { DatatableColumn } from '../../../../expressions/public'; +import type { SerializedFieldFormat } from '../../../../field_formats/common'; import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { ExpressionValuePieLabels } from '../expression_functions/pie_labels'; import { PaletteOutput, ChartsPluginSetup } from '../../../../charts/public'; diff --git a/src/plugins/vis_types/pie/public/utils/get_layers.ts b/src/plugins/vis_types/pie/public/utils/get_layers.ts index 6ecef858619b58..c9d8da15b78f6d 100644 --- a/src/plugins/vis_types/pie/public/utils/get_layers.ts +++ b/src/plugins/vis_types/pie/public/utils/get_layers.ts @@ -133,7 +133,6 @@ export const getLayers = ( syncColors: boolean ): PartitionLayer[] => { const fillLabel: Partial = { - textInvertible: true, valueFont: { fontWeight: 700, }, diff --git a/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx b/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx index d7b7bb14723d72..e6d2638bedf480 100644 --- a/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx +++ b/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx @@ -64,6 +64,8 @@ const DefaultYAxis = () => ( id="left" domain={withStaticPadding({ fit: false, + min: NaN, + max: NaN, })} position={Position.Left} groupId={`${MAIN_GROUP_ID}`} diff --git a/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts b/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts index 3c76b95bd05ca0..98be5efc55a26b 100644 --- a/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts +++ b/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts @@ -88,8 +88,8 @@ const adaptYaxisParams = (yaxis: IAxis) => { tickFormat: y.tickFormatter, domain: withStaticPadding({ fit: y.min === undefined && y.max === undefined, - min: y.min, - max: y.max, + min: y.min ?? NaN, + max: y.max ?? NaN, }), }; }; @@ -118,6 +118,8 @@ export const extractAllYAxis = (series: Series[]) => { groupId, domain: withStaticPadding({ fit: false, + min: NaN, + max: NaN, }), id: (yaxis?.position || Position.Left) + index, position: Position.Left, diff --git a/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js b/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js index cfeed174307ac4..13c17b8f4c38fd 100644 --- a/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js +++ b/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js @@ -81,6 +81,20 @@ describe(`VegaParser.parseAsync`, () => { }) ) ); + + test(`should return a specific error in case of $schema URL not valid`, async () => { + const vp = new VegaParser({ + $schema: 'https://vega.github.io/schema/vega-lite/v4.jsonanythingtobreakthis', + mark: 'circle', + encoding: { row: { field: 'a' } }, + }); + + await vp.parseAsync(); + + expect(vp.error).toBe( + 'The URL for the JSON "$schema" is incorrect. Correct the URL, then click Update.' + ); + }); }); describe(`VegaParser._setDefaultValue`, () => { diff --git a/src/plugins/vis_types/vega/public/data_model/vega_parser.ts b/src/plugins/vis_types/vega/public/data_model/vega_parser.ts index 9000fed7f6116f..bf2a6be25c71aa 100644 --- a/src/plugins/vis_types/vega/public/data_model/vega_parser.ts +++ b/src/plugins/vis_types/vega/public/data_model/vega_parser.ts @@ -553,25 +553,37 @@ The URL is an identifier only. Kibana and your browser will never access this UR * @private */ private parseSchema(spec: VegaSpec) { - const schema = schemaParser(spec.$schema); - const isVegaLite = schema.library === 'vega-lite'; - const libVersion = isVegaLite ? vegaLiteVersion : vegaVersion; + try { + const schema = schemaParser(spec.$schema); + const isVegaLite = schema.library === 'vega-lite'; + const libVersion = isVegaLite ? vegaLiteVersion : vegaVersion; - if (versionCompare(schema.version, libVersion) > 0) { - this._onWarning( - i18n.translate('visTypeVega.vegaParser.notValidLibraryVersionForInputSpecWarningMessage', { + if (versionCompare(schema.version, libVersion) > 0) { + this._onWarning( + i18n.translate( + 'visTypeVega.vegaParser.notValidLibraryVersionForInputSpecWarningMessage', + { + defaultMessage: + 'The input spec uses {schemaLibrary} {schemaVersion}, but current version of {schemaLibrary} is {libraryVersion}.', + values: { + schemaLibrary: schema.library, + schemaVersion: schema.version, + libraryVersion: libVersion, + }, + } + ) + ); + } + + return { isVegaLite, libVersion }; + } catch (e) { + throw Error( + i18n.translate('visTypeVega.vegaParser.notValidSchemaForInputSpec', { defaultMessage: - 'The input spec uses {schemaLibrary} {schemaVersion}, but current version of {schemaLibrary} is {libraryVersion}.', - values: { - schemaLibrary: schema.library, - schemaVersion: schema.version, - libraryVersion: libVersion, - }, + 'The URL for the JSON "$schema" is incorrect. Correct the URL, then click Update.', }) ); } - - return { isVegaLite, libVersion }; } /** diff --git a/src/plugins/vis_types/xy/public/components/xy_settings.tsx b/src/plugins/vis_types/xy/public/components/xy_settings.tsx index 5e02b65822d6cf..74aff7535c2d81 100644 --- a/src/plugins/vis_types/xy/public/components/xy_settings.tsx +++ b/src/plugins/vis_types/xy/public/components/xy_settings.tsx @@ -71,7 +71,6 @@ function getValueLabelsStyling() { return { displayValue: { fontSize: { min: VALUE_LABELS_MIN_FONTSIZE, max: VALUE_LABELS_MAX_FONTSIZE }, - fill: { textInverted: false, textContrast: true }, alignment: { horizontal: HorizontalAlignment.Center, vertical: VerticalAlignment.Middle }, }, }; diff --git a/src/plugins/vis_types/xy/public/config/get_axis.ts b/src/plugins/vis_types/xy/public/config/get_axis.ts index b5cc96830e46a3..09495725296cd6 100644 --- a/src/plugins/vis_types/xy/public/config/get_axis.ts +++ b/src/plugins/vis_types/xy/public/config/get_axis.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { identity, isNil } from 'lodash'; +import { identity } from 'lodash'; import { AxisSpec, TickFormatter, YDomainRange, ScaleType as ECScaleType } from '@elastic/charts'; @@ -171,17 +171,5 @@ function getAxisDomain( const fit = defaultYExtents; const padding = boundsMargin || undefined; - if (!isNil(min) && !isNil(max)) { - return { fit, padding, min, max }; - } - - if (!isNil(min)) { - return { fit, padding, min }; - } - - if (!isNil(max)) { - return { fit, padding, max }; - } - - return { fit, padding }; + return { fit, padding, min: min ?? NaN, max: max ?? NaN }; } diff --git a/src/plugins/vis_types/xy/public/utils/domain.ts b/src/plugins/vis_types/xy/public/utils/domain.ts index fa8dd74e3942a4..5b1310863979a3 100644 --- a/src/plugins/vis_types/xy/public/utils/domain.ts +++ b/src/plugins/vis_types/xy/public/utils/domain.ts @@ -33,6 +33,8 @@ export const getXDomain = (params: Aspect['params']): DomainRange => { return { minInterval, + min: NaN, + max: NaN, }; }; @@ -74,9 +76,9 @@ export const getAdjustedDomain = ( }; } - return 'interval' in params - ? { - minInterval: params.interval, - } - : {}; + return { + minInterval: 'interval' in params ? params.interval : undefined, + min: NaN, + max: NaN, + }; }; diff --git a/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts b/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts index 5fe1b03dd8b939..c14e313b1e7a40 100644 --- a/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts +++ b/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts @@ -112,7 +112,10 @@ export const getVisConfig = (): VisConfig => { mode: AxisMode.Normal, type: 'linear', }, - domain: {}, + domain: { + min: NaN, + max: NaN, + }, integersOnly: false, }, ], @@ -246,7 +249,10 @@ export const getVisConfigMutipleYaxis = (): VisConfig => { mode: AxisMode.Normal, type: 'linear', }, - domain: {}, + domain: { + min: NaN, + max: NaN, + }, integersOnly: false, }, ], @@ -435,7 +441,10 @@ export const getVisConfigPercentiles = (): VisConfig => { mode: AxisMode.Normal, type: 'linear', }, - domain: {}, + domain: { + min: NaN, + max: NaN, + }, integersOnly: false, }, ], diff --git a/src/plugins/vis_types/xy/public/vis_component.tsx b/src/plugins/vis_types/xy/public/vis_component.tsx index f4d566f49602e0..515ad3e7eaf6fa 100644 --- a/src/plugins/vis_types/xy/public/vis_component.tsx +++ b/src/plugins/vis_types/xy/public/vis_component.tsx @@ -19,6 +19,7 @@ import { ScaleType, AccessorFn, Accessor, + XYBrushEvent, } from '@elastic/charts'; import { compact } from 'lodash'; @@ -131,7 +132,10 @@ const VisComponent = (props: VisComponentProps) => { ): BrushEndListener | undefined => { if (xAccessor !== null && isInterval) { return (brushArea) => { - const event = getBrushFromChartBrushEventFn(visData, xAccessor)(brushArea); + const event = getBrushFromChartBrushEventFn( + visData, + xAccessor + )(brushArea as XYBrushEvent); props.fireEvent(event); }; } diff --git a/src/plugins/visualizations/common/expression_functions/xy_dimension.ts b/src/plugins/visualizations/common/expression_functions/xy_dimension.ts index 82538fea8605a9..5bbddd48e9b8b3 100644 --- a/src/plugins/visualizations/common/expression_functions/xy_dimension.ts +++ b/src/plugins/visualizations/common/expression_functions/xy_dimension.ts @@ -14,8 +14,8 @@ import type { ExpressionValueBoxed, Datatable, DatatableColumn, - SerializedFieldFormat, } from '../../../expressions/common'; +import type { SerializedFieldFormat } from '../../../field_formats/common'; export interface DateHistogramParams { date: boolean; diff --git a/src/plugins/visualizations/public/vis_schemas.ts b/src/plugins/visualizations/public/vis_schemas.ts index 115e13ece45ff1..f80f85fb55a608 100644 --- a/src/plugins/visualizations/public/vis_schemas.ts +++ b/src/plugins/visualizations/public/vis_schemas.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SerializedFieldFormat } from '../../expressions/public'; +import type { SerializedFieldFormat } from '../../field_formats/common'; import { IAggConfig, search } from '../../data/public'; import { Vis, VisToExpressionAstParams } from './types'; diff --git a/test/functional/apps/dashboard/dashboard_state.ts b/test/functional/apps/dashboard/dashboard_state.ts index 45ba62749dd775..0cc0fa48064826 100644 --- a/test/functional/apps/dashboard/dashboard_state.ts +++ b/test/functional/apps/dashboard/dashboard_state.ts @@ -7,6 +7,7 @@ */ import expect from '@kbn/expect'; +import chroma from 'chroma-js'; import { PIE_CHART_VIS_NAME, AREA_CHART_VIS_NAME } from '../../page_objects/dashboard_page'; import { DEFAULT_PANEL_WIDTH } from '../../../../src/plugins/dashboard/public/application/embeddable/dashboard_constants'; @@ -264,14 +265,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { - const allPieSlicesColor = await pieChart.getAllPieSliceStyles('80,000'); - let whitePieSliceCounts = 0; - allPieSlicesColor.forEach((style) => { - if (style.indexOf('rgb(255, 255, 255)') > -1) { - whitePieSliceCounts++; - } - }); - + const allPieSlicesColor = await pieChart.getAllPieSliceColor('80,000'); + const whitePieSliceCounts = allPieSlicesColor.reduce((count, color) => { + // converting the color to a common format, testing the color, not the string format + return chroma(color).hex().toUpperCase() === '#FFFFFF' ? count + 1 : count; + }, 0); expect(whitePieSliceCounts).to.be(1); }); }); diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index d2e4091f935776..b0e9e21d07b0bc 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -7,7 +7,7 @@ */ import { Position } from '@elastic/charts'; -import Color from 'color'; +import chroma from 'chroma-js'; import { FtrService } from '../ftr_provider_context'; @@ -181,17 +181,17 @@ export class VisualizeChartPageObject extends FtrService { return items.some(({ color: c }) => c === color); } - public async doesSelectedLegendColorExistForPie(color: string) { + public async doesSelectedLegendColorExistForPie(matchingColor: string) { if (await this.isNewLibraryChart(pieChartSelector)) { + const hexMatchingColor = chroma(matchingColor).hex().toUpperCase(); const slices = (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; - return slices.some(({ color: c }) => { - const rgbColor = new Color(color).rgb().toString(); - return c === rgbColor; + return slices.some(({ color }) => { + return hexMatchingColor === chroma(color).hex().toUpperCase(); }); } - return await this.testSubjects.exists(`legendSelectedColor-${color}`); + return await this.testSubjects.exists(`legendSelectedColor-${matchingColor}`); } public async expectError() { diff --git a/test/functional/services/visualizations/pie_chart.ts b/test/functional/services/visualizations/pie_chart.ts index 7c925318f0211f..ff0c24e2830cfa 100644 --- a/test/functional/services/visualizations/pie_chart.ts +++ b/test/functional/services/visualizations/pie_chart.ts @@ -7,6 +7,7 @@ */ import expect from '@kbn/expect'; +import { isNil } from 'lodash'; import { FtrService } from '../../ftr_provider_context'; const pieChartSelector = 'visTypePieChart'; @@ -100,8 +101,8 @@ export class PieChartService extends FtrService { return await pieSlice.getAttribute('style'); } - async getAllPieSliceStyles(name: string) { - this.log.debug(`VisualizePage.getAllPieSliceStyles(${name})`); + async getAllPieSliceColor(name: string) { + this.log.debug(`VisualizePage.getAllPieSliceColor(${name})`); if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? @@ -112,9 +113,22 @@ export class PieChartService extends FtrService { return selectedSlice.map((slice) => slice.color); } const pieSlices = await this.getAllPieSlices(name); - return await Promise.all( + const slicesStyles = await Promise.all( pieSlices.map(async (pieSlice) => await pieSlice.getAttribute('style')) ); + return slicesStyles + .map( + (styles) => + styles.split(';').reduce>((styleAsObj, style) => { + const stylePair = style.split(':'); + if (stylePair.length !== 2) { + return styleAsObj; + } + styleAsObj[stylePair[0].trim()] = stylePair[1].trim(); + return styleAsObj; + }, {}).fill // in vislib the color is available on the `fill` style prop + ) + .filter((d) => !isNil(d)); } async getPieChartData() { diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index b19e89a599840b..0d66c9d30f8b97 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -33,29 +33,36 @@ Table of Contents - [actionsClient.execute(options)](#actionsclientexecuteoptions) - [Example](#example-2) - [Built-in Action Types](#built-in-action-types) - - [ServiceNow](#servicenow) + - [ServiceNow ITSM](#servicenow-itsm) - [`params`](#params) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice) - [`subActionParams (getFields)`](#subactionparams-getfields) - [`subActionParams (getIncident)`](#subactionparams-getincident) - [`subActionParams (getChoices)`](#subactionparams-getchoices) - - [Jira](#jira) + - [ServiceNow Sec Ops](#servicenow-sec-ops) - [`params`](#params-1) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1) + - [`subActionParams (getFields)`](#subactionparams-getfields-1) - [`subActionParams (getIncident)`](#subactionparams-getincident-1) + - [`subActionParams (getChoices)`](#subactionparams-getchoices-1) + - [| fields | An array of fields. Example: `[priority, category]`. | string[] |](#-fields----an-array-of-fields-example-priority-category--string-) + - [Jira](#jira) + - [`params`](#params-2) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) + - [`subActionParams (getIncident)`](#subactionparams-getincident-2) - [`subActionParams (issueTypes)`](#subactionparams-issuetypes) - [`subActionParams (fieldsByIssueType)`](#subactionparams-fieldsbyissuetype) - [`subActionParams (issues)`](#subactionparams-issues) - [`subActionParams (issue)`](#subactionparams-issue) - - [`subActionParams (getFields)`](#subactionparams-getfields-1) - - [IBM Resilient](#ibm-resilient) - - [`params`](#params-2) - - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) - [`subActionParams (getFields)`](#subactionparams-getfields-2) + - [IBM Resilient](#ibm-resilient) + - [`params`](#params-3) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3) + - [`subActionParams (getFields)`](#subactionparams-getfields-3) - [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes) - [`subActionParams (severity)`](#subactionparams-severity) - [Swimlane](#swimlane) - - [`params`](#params-3) + - [`params`](#params-4) - [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) @@ -246,9 +253,9 @@ Kibana ships with a set of built-in action types. See [Actions and connector typ In addition to the documented configurations, several built in action type offer additional `params` configurations. -## ServiceNow +## ServiceNow ITSM -The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. +The [ServiceNow ITSM user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. ### `params` | Property | Description | Type | @@ -265,16 +272,18 @@ The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kib The following table describes the properties of the `incident` object. -| Property | Description | Type | -| ----------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- | -| short_description | The title of the incident. | string | -| description | The description of the incident. | string _(optional)_ | -| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | -| severity | The severity in ServiceNow. | string _(optional)_ | -| urgency | The urgency in ServiceNow. | string _(optional)_ | -| impact | The impact in ServiceNow. | string _(optional)_ | -| category | The category in ServiceNow. | string _(optional)_ | -| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| Property | Description | Type | +| ------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- | +| short_description | The title of the incident. | string | +| description | The description of the incident. | string _(optional)_ | +| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | +| severity | The severity in ServiceNow. | string _(optional)_ | +| urgency | The urgency in ServiceNow. | string _(optional)_ | +| impact | The impact in ServiceNow. | string _(optional)_ | +| category | The category in ServiceNow. | string _(optional)_ | +| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| correlation_id | The correlation id of the incident. | string _(optional)_ | +| correlation_display | The correlation display of the ServiceNow. | string _(optional)_ | #### `subActionParams (getFields)` @@ -289,12 +298,64 @@ No parameters for the `getFields` subaction. Provide an empty object `{}`. #### `subActionParams (getChoices)` -| Property | Description | Type | -| -------- | ------------------------------------------------------------ | -------- | -| fields | An array of fields. Example: `[priority, category, impact]`. | string[] | +| Property | Description | Type | +| -------- | -------------------------------------------------- | -------- | +| fields | An array of fields. Example: `[category, impact]`. | string[] | --- +## ServiceNow Sec Ops + +The [ServiceNow SecOps user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-sir-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. + +### `params` + +| Property | Description | Type | +| --------------- | -------------------------------------------------------------------------------------------------- | ------ | +| subAction | The subaction to perform. It can be `pushToService`, `getFields`, `getIncident`, and `getChoices`. | string | +| subActionParams | The parameters of the subaction. | object | + +#### `subActionParams (pushToService)` + +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------------------------- | --------------------- | +| incident | The ServiceNow security incident. | object | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ | + +The following table describes the properties of the `incident` object. + +| Property | Description | Type | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| short_description | The title of the security incident. | string | +| description | The description of the security incident. | string _(optional)_ | +| externalId | The ID of the security incident in ServiceNow. If present, the security incident is updated. Otherwise, a new security incident is created. | string _(optional)_ | +| priority | The priority in ServiceNow. | string _(optional)_ | +| dest_ip | A list of destination IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| source_ip | A list of source IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| malware_hash | A list of malware hashes related to the security incident. The hashes will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| malware_url | A list of malware URLs related to the security incident. The URLs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| category | The category in ServiceNow. | string _(optional)_ | +| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| correlation_id | The correlation id of the security incident. | string _(optional)_ | +| correlation_display | The correlation display of the security incident. | string _(optional)_ | + +#### `subActionParams (getFields)` + +No parameters for the `getFields` subaction. Provide an empty object `{}`. + +#### `subActionParams (getIncident)` + +| Property | Description | Type | +| ---------- | ---------------------------------------------- | ------ | +| externalId | The ID of the security incident in ServiceNow. | string | + + +#### `subActionParams (getChoices)` + +| Property | Description | Type | +| -------- | ---------------------------------------------------- | -------- | +| fields | An array of fields. Example: `[priority, category]`. | string[] | +--- ## Jira The [Jira user documentation `params`](https://www.elastic.co/guide/en/kibana/master/jira-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts index 5d83b658111e48..7710ff79d08b4a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -143,7 +143,7 @@ export function getActionType({ }), validate: { config: schema.object(configSchemaProps, { - validate: curry(valdiateActionTypeConfig)(configurationUtilities), + validate: curry(validateActionTypeConfig)(configurationUtilities), }), secrets: SecretsSchema, params: ParamsSchema, @@ -152,7 +152,7 @@ export function getActionType({ }; } -function valdiateActionTypeConfig( +function validateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, configObject: ActionTypeConfigType ) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 8d24e48d4d5150..e1f66263729e2f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -25,6 +25,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -57,6 +58,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -78,6 +80,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: { username: 'elastic', password: 'elastic' }, logger: mockedLogger, commentFieldKey: 'comments', @@ -93,6 +96,9 @@ describe('api', () => { caller_id: 'elastic', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', + opened_by: 'elastic', }, }); expect(externalService.updateIncident).not.toHaveBeenCalled(); @@ -103,6 +109,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -118,6 +125,8 @@ describe('api', () => { comments: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -132,6 +141,8 @@ describe('api', () => { comments: 'Another comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -142,6 +153,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', @@ -157,6 +169,8 @@ describe('api', () => { work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -171,6 +185,8 @@ describe('api', () => { work_notes: 'Another comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -182,6 +198,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params: apiParams, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -210,6 +227,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -228,6 +246,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -243,6 +262,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, }); expect(externalService.createIncident).not.toHaveBeenCalled(); @@ -253,6 +274,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -267,6 +289,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-3', }); @@ -281,6 +305,8 @@ describe('api', () => { comments: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-2', }); @@ -291,6 +317,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', @@ -305,6 +332,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-3', }); @@ -319,6 +348,8 @@ describe('api', () => { work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-2', }); @@ -344,4 +375,23 @@ describe('api', () => { expect(res).toEqual(serviceNowChoices); }); }); + + describe('getIncident', () => { + test('it gets the incident correctly', async () => { + const res = await api.getIncident({ + externalService, + params: { + externalId: 'incident-1', + }, + }); + expect(res).toEqual({ + description: 'description from servicenow', + id: 'incident-1', + pushedDate: '2020-03-10T12:24:20.000Z', + short_description: 'title from servicenow', + title: 'INC01', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts index 4120c07c32303f..88cdfd069cf1be 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts @@ -6,7 +6,7 @@ */ import { - ExternalServiceApi, + ExternalServiceAPI, GetChoicesHandlerArgs, GetChoicesResponse, GetCommonFieldsHandlerArgs, @@ -19,7 +19,11 @@ import { } from './types'; const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {}; -const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {}; +const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => { + const { externalId: id } = params; + const res = await externalService.getIncident(id); + return res; +}; const pushToServiceHandler = async ({ externalService, @@ -42,6 +46,7 @@ const pushToServiceHandler = async ({ incident: { ...incident, caller_id: secrets.username, + opened_by: secrets.username, }, }); } @@ -84,7 +89,7 @@ const getChoicesHandler = async ({ return res; }; -export const api: ExternalServiceApi = { +export const api: ExternalServiceAPI = { getChoices: getChoicesHandler, getFields: getFieldsHandler, getIncident: getIncidentHandler, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts new file mode 100644 index 00000000000000..358af7cd2e9ef7 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts @@ -0,0 +1,286 @@ +/* + * 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 { Logger } from '../../../../../../src/core/server'; +import { externalServiceSIRMock, sirParams } from './mocks'; +import { ExternalServiceSIR, ObservableTypes } from './types'; +import { apiSIR, combineObservables, formatObservables, prepareParams } from './api_sir'; +let mockedLogger: jest.Mocked; + +describe('api_sir', () => { + let externalService: jest.Mocked; + + beforeEach(() => { + externalService = externalServiceSIRMock.create(); + jest.clearAllMocks(); + }); + + describe('combineObservables', () => { + test('it returns an empty array when both arguments are an empty array', async () => { + expect(combineObservables([], [])).toEqual([]); + }); + + test('it returns an empty array when both arguments are an empty string', async () => { + expect(combineObservables('', '')).toEqual([]); + }); + + test('it returns an empty array when a="" and b=[]', async () => { + expect(combineObservables('', [])).toEqual([]); + }); + + test('it returns an empty array when a=[] and b=""', async () => { + expect(combineObservables([], '')).toEqual([]); + }); + + test('it returns a if b is empty', async () => { + expect(combineObservables('a', '')).toEqual(['a']); + }); + + test('it returns b if a is empty', async () => { + expect(combineObservables([], ['b'])).toEqual(['b']); + }); + + test('it combines two strings', async () => { + expect(combineObservables('a,b', 'c,d')).toEqual(['a', 'b', 'c', 'd']); + }); + + test('it combines two arrays', async () => { + expect(combineObservables(['a'], ['b'])).toEqual(['a', 'b']); + }); + + test('it combines a string with an array', async () => { + expect(combineObservables('a', ['b'])).toEqual(['a', 'b']); + }); + + test('it combines an array with a string ', async () => { + expect(combineObservables(['a'], 'b')).toEqual(['a', 'b']); + }); + + test('it combines a "," concatenated string', async () => { + expect(combineObservables(['a'], 'b,c,d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b,c,d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "|" concatenated string', async () => { + expect(combineObservables(['a'], 'b|c|d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b|c|d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a space concatenated string', async () => { + expect(combineObservables(['a'], 'b c d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b c d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\n" concatenated string', async () => { + expect(combineObservables(['a'], 'b\nc\nd')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\nc\nd', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\r" concatenated string', async () => { + expect(combineObservables(['a'], 'b\rc\rd')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\rc\rd', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\t" concatenated string', async () => { + expect(combineObservables(['a'], 'b\tc\td')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\tc\td', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines two strings with different delimiter', async () => { + expect(combineObservables('a|b|c', 'd e f')).toEqual(['a', 'b', 'c', 'd', 'e', 'f']); + }); + }); + + describe('formatObservables', () => { + test('it formats array observables correctly', async () => { + const expectedTypes: Array<[ObservableTypes, string]> = [ + [ObservableTypes.ip4, 'ipv4-addr'], + [ObservableTypes.sha256, 'SHA256'], + [ObservableTypes.url, 'URL'], + ]; + + for (const type of expectedTypes) { + expect(formatObservables(['a', 'b', 'c'], type[0])).toEqual([ + { type: type[1], value: 'a' }, + { type: type[1], value: 'b' }, + { type: type[1], value: 'c' }, + ]); + } + }); + + test('it removes duplicates from array observables correctly', async () => { + expect(formatObservables(['a', 'a', 'c'], ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + + test('it formats an empty array correctly', async () => { + expect(formatObservables([], ObservableTypes.ip4)).toEqual([]); + }); + + test('it removes empty observables correctly', async () => { + expect(formatObservables(['a', '', 'c'], ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + }); + + describe('prepareParams', () => { + test('it prepares the params correctly when the connector is legacy', async () => { + expect(prepareParams(true, sirParams)).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: '192.168.1.1,192.168.1.3', + source_ip: '192.168.1.2,192.168.1.4', + malware_hash: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + malware_url: 'https://example.com', + }, + }); + }); + + test('it prepares the params correctly when the connector is not legacy', async () => { + expect(prepareParams(false, sirParams)).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + }, + }); + }); + + test('it prepares the params correctly when the connector is legacy and the observables are undefined', async () => { + const { + dest_ip: destIp, + source_ip: sourceIp, + malware_hash: malwareHash, + malware_url: malwareURL, + ...incidentWithoutObservables + } = sirParams.incident; + + expect( + prepareParams(true, { + ...sirParams, + // @ts-expect-error + incident: incidentWithoutObservables, + }) + ).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + }, + }); + }); + }); + + describe('pushToService', () => { + test('it creates an incident correctly', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + const res = await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(res).toEqual({ + id: 'incident-1', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ], + }); + }); + + test('it adds observables correctly', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).toHaveBeenCalledWith( + [ + { type: 'ipv4-addr', value: '192.168.1.1' }, + { type: 'ipv4-addr', value: '192.168.1.3' }, + { type: 'ipv4-addr', value: '192.168.1.2' }, + { type: 'ipv4-addr', value: '192.168.1.4' }, + { + type: 'SHA256', + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + }, + { type: 'URL', value: 'https://example.com' }, + ], + // createIncident mock returns this incident id + 'incident-1' + ); + }); + + test('it does not call bulkAddObservableToIncident if it a legacy connector', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: true }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled(); + }); + + test('it does not call bulkAddObservableToIncident if there are no observables', async () => { + const params = { + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + externalId: null, + }, + }; + + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts new file mode 100644 index 00000000000000..326bb79a0e708c --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -0,0 +1,154 @@ +/* + * 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 { isEmpty, isString } from 'lodash'; + +import { + ExecutorSubActionPushParamsSIR, + ExternalServiceAPI, + ExternalServiceSIR, + ObservableTypes, + PushToServiceApiHandlerArgs, + PushToServiceApiParamsSIR, + PushToServiceResponse, +} from './types'; + +import { api } from './api'; + +const SPLIT_REGEX = /[ ,|\r\n\t]+/; + +export const formatObservables = (observables: string[], type: ObservableTypes) => { + /** + * ServiceNow accepted formats are: comma, new line, tab, or pipe separators. + * Before the application the observables were being sent to ServiceNow as a concatenated string with + * delimiter. With the application the format changed to an array of observables. + */ + const uniqueObservables = new Set(observables); + return [...uniqueObservables].filter((obs) => !isEmpty(obs)).map((obs) => ({ value: obs, type })); +}; + +const obsAsArray = (obs: string | string[]): string[] => { + if (isEmpty(obs)) { + return []; + } + + if (isString(obs)) { + return obs.split(SPLIT_REGEX); + } + + return obs; +}; + +export const combineObservables = (a: string | string[], b: string | string[]): string[] => { + const first = obsAsArray(a); + const second = obsAsArray(b); + + return [...first, ...second]; +}; + +const observablesToString = (obs: string | string[] | null | undefined): string | null => { + if (Array.isArray(obs)) { + return obs.join(','); + } + + return obs ?? null; +}; + +export const prepareParams = ( + isLegacy: boolean, + params: PushToServiceApiParamsSIR +): PushToServiceApiParamsSIR => { + if (isLegacy) { + /** + * The schema has change to accept an array of observables + * or a string. In the case of a legacy connector we need to + * convert the observables to a string + */ + return { + ...params, + incident: { + ...params.incident, + dest_ip: observablesToString(params.incident.dest_ip), + malware_hash: observablesToString(params.incident.malware_hash), + malware_url: observablesToString(params.incident.malware_url), + source_ip: observablesToString(params.incident.source_ip), + }, + }; + } + + /** + * For non legacy connectors the observables + * will be added in a different call. + * They need to be set to null when sending the fields + * to ServiceNow + */ + return { + ...params, + incident: { + ...params.incident, + dest_ip: null, + malware_hash: null, + malware_url: null, + source_ip: null, + }, + }; +}; + +const pushToServiceHandler = async ({ + externalService, + params, + config, + secrets, + commentFieldKey, + logger, +}: PushToServiceApiHandlerArgs): Promise => { + const res = await api.pushToService({ + externalService, + params: prepareParams(!!config.isLegacy, params as PushToServiceApiParamsSIR), + config, + secrets, + commentFieldKey, + logger, + }); + + const { + incident: { + dest_ip: destIP, + malware_hash: malwareHash, + malware_url: malwareUrl, + source_ip: sourceIP, + }, + } = params as ExecutorSubActionPushParamsSIR; + + /** + * Add bulk observables is only available for new connectors + * Old connectors gonna add their observables + * through the pushToService call. + */ + + if (!config.isLegacy) { + const sirExternalService = externalService as ExternalServiceSIR; + + const obsWithType: Array<[string[], ObservableTypes]> = [ + [combineObservables(destIP ?? [], sourceIP ?? []), ObservableTypes.ip4], + [obsAsArray(malwareHash ?? []), ObservableTypes.sha256], + [obsAsArray(malwareUrl ?? []), ObservableTypes.url], + ]; + + const observables = obsWithType.map(([obs, type]) => formatObservables(obs, type)).flat(); + if (observables.length > 0) { + await sirExternalService.bulkAddObservableToIncident(observables, res.id); + } + } + + return res; +}; + +export const apiSIR: ExternalServiceAPI = { + ...api, + pushToService: pushToServiceHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts new file mode 100644 index 00000000000000..babd360cbcb826 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { snExternalServiceConfig } from './config'; + +/** + * The purpose of this test is to + * prevent developers from accidentally + * change important configuration values + * such as the scope or the import set table + * of our ServiceNow application + */ + +describe('config', () => { + test('ITSM: the config are correct', async () => { + const snConfig = snExternalServiceConfig['.servicenow']; + expect(snConfig).toEqual({ + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'incident', + useImportAPI: true, + commentFieldKey: 'work_notes', + }); + }); + + test('SIR: the config are correct', async () => { + const snConfig = snExternalServiceConfig['.servicenow-sir']; + expect(snConfig).toEqual({ + importSetTable: 'x_elas2_sir_int_elastic_si_incident', + appScope: 'x_elas2_sir_int', + table: 'sn_si_incident', + useImportAPI: true, + commentFieldKey: 'work_notes', + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts new file mode 100644 index 00000000000000..37e4c6994b4033 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, +} from '../../constants/connectors'; +import { SNProductsConfig } from './types'; + +export const serviceNowITSMTable = 'incident'; +export const serviceNowSIRTable = 'sn_si_incident'; + +export const ServiceNowITSMActionTypeId = '.servicenow'; +export const ServiceNowSIRActionTypeId = '.servicenow-sir'; + +export const snExternalServiceConfig: SNProductsConfig = { + '.servicenow': { + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'incident', + useImportAPI: ENABLE_NEW_SN_ITSM_CONNECTOR, + commentFieldKey: 'work_notes', + }, + '.servicenow-sir': { + importSetTable: 'x_elas2_sir_int_elastic_si_incident', + appScope: 'x_elas2_sir_int', + table: 'sn_si_incident', + useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR, + commentFieldKey: 'work_notes', + }, +}; + +export const FIELD_PREFIX = 'u_'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index f2b500df6ccb34..29907381d45da1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -18,7 +18,7 @@ import { import { ActionsConfigurationUtilities } from '../../actions_config'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types'; import { createExternalService } from './service'; -import { api } from './api'; +import { api as commonAPI } from './api'; import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; import { @@ -30,7 +30,25 @@ import { ExecutorSubActionCommonFieldsParams, ServiceNowExecutorResultData, ExecutorSubActionGetChoicesParams, + ServiceFactory, + ExternalServiceAPI, } from './types'; +import { + ServiceNowITSMActionTypeId, + serviceNowITSMTable, + ServiceNowSIRActionTypeId, + serviceNowSIRTable, + snExternalServiceConfig, +} from './config'; +import { createExternalServiceSIR } from './service_sir'; +import { apiSIR } from './api_sir'; + +export { + ServiceNowITSMActionTypeId, + serviceNowITSMTable, + ServiceNowSIRActionTypeId, + serviceNowSIRTable, +}; export type ActionParamsType = | TypeOf @@ -41,12 +59,6 @@ interface GetActionTypeParams { configurationUtilities: ActionsConfigurationUtilities; } -const serviceNowITSMTable = 'incident'; -const serviceNowSIRTable = 'sn_si_incident'; - -export const ServiceNowITSMActionTypeId = '.servicenow'; -export const ServiceNowSIRActionTypeId = '.servicenow-sir'; - export type ServiceNowActionType = ActionType< ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType, @@ -79,8 +91,9 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic executor: curry(executor)({ logger, configurationUtilities, - table: serviceNowITSMTable, - commentFieldKey: 'work_notes', + actionTypeId: ServiceNowITSMActionTypeId, + createService: createExternalService, + api: commonAPI, }), }; } @@ -103,8 +116,9 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service executor: curry(executor)({ logger, configurationUtilities, - table: serviceNowSIRTable, - commentFieldKey: 'work_notes', + actionTypeId: ServiceNowSIRActionTypeId, + createService: createExternalServiceSIR, + api: apiSIR, }), }; } @@ -115,28 +129,31 @@ async function executor( { logger, configurationUtilities, - table, - commentFieldKey = 'comments', + actionTypeId, + createService, + api, }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; - table: string; - commentFieldKey?: string; + actionTypeId: string; + createService: ServiceFactory; + api: ExternalServiceAPI; }, execOptions: ServiceNowActionTypeExecutorOptions ): Promise> { const { actionId, config, params, secrets } = execOptions; const { subAction, subActionParams } = params; + const externalServiceConfig = snExternalServiceConfig[actionTypeId]; let data: ServiceNowExecutorResultData | null = null; - const externalService = createExternalService( - table, + const externalService = createService( { config, secrets, }, logger, - configurationUtilities + configurationUtilities, + externalServiceConfig ); if (!api[subAction]) { @@ -156,9 +173,10 @@ async function executor( data = await api.pushToService({ externalService, params: pushToServiceParams, + config, secrets, logger, - commentFieldKey, + commentFieldKey: externalServiceConfig.commentFieldKey, }); logger.debug(`response push to service for incident id: ${data.id}`); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index 909200472be332..3629fb33915aef 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -5,7 +5,14 @@ * 2.0. */ -import { ExternalService, ExecutorSubActionPushParams } from './types'; +import { + ExternalService, + ExecutorSubActionPushParams, + PushToServiceApiParamsSIR, + ExternalServiceSIR, + Observable, + ObservableTypes, +} from './types'; export const serviceNowCommonFields = [ { @@ -74,6 +81,10 @@ const createMock = (): jest.Mocked => { getFields: jest.fn().mockImplementation(() => Promise.resolve(serviceNowCommonFields)), getIncident: jest.fn().mockImplementation(() => Promise.resolve({ + id: 'incident-1', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', short_description: 'title from servicenow', description: 'description from servicenow', }) @@ -95,16 +106,60 @@ const createMock = (): jest.Mocked => { }) ), findIncidents: jest.fn(), + getApplicationInformation: jest.fn().mockImplementation(() => + Promise.resolve({ + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }) + ), + checkIfApplicationIsInstalled: jest.fn(), + getUrl: jest.fn().mockImplementation(() => 'https://instance.service-now.com'), + checkInstance: jest.fn(), }; return service; }; -const externalServiceMock = { +const createSIRMock = (): jest.Mocked => { + const service = { + ...createMock(), + addObservableToIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + value: 'https://example.com', + observable_sys_id: '3', + }) + ), + bulkAddObservableToIncident: jest.fn().mockImplementation(() => + Promise.resolve([ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + observable_sys_id: '1', + }, + { + value: '127.0.0.1', + observable_sys_id: '2', + }, + { + value: 'https://example.com', + observable_sys_id: '3', + }, + ]) + ), + }; + + return service; +}; + +export const externalServiceMock = { create: createMock, }; -const executorParams: ExecutorSubActionPushParams = { +export const externalServiceSIRMock = { + create: createSIRMock, +}; + +export const executorParams: ExecutorSubActionPushParams = { incident: { externalId: 'incident-3', short_description: 'Incident title', @@ -114,6 +169,8 @@ const executorParams: ExecutorSubActionPushParams = { impact: '3', category: 'software', subcategory: 'os', + correlation_id: 'ruleId', + correlation_display: 'Alerting', }, comments: [ { @@ -127,6 +184,46 @@ const executorParams: ExecutorSubActionPushParams = { ], }; -const apiParams = executorParams; +export const sirParams: PushToServiceApiParamsSIR = { + incident: { + externalId: 'incident-3', + short_description: 'Incident title', + description: 'Incident description', + dest_ip: ['192.168.1.1', '192.168.1.3'], + source_ip: ['192.168.1.2', '192.168.1.4'], + malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'], + malware_url: ['https://example.com'], + category: 'software', + subcategory: 'os', + correlation_id: 'ruleId', + correlation_display: 'Alerting', + priority: '1', + }, + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + }, + ], +}; + +export const observables: Observable[] = [ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + type: ObservableTypes.sha256, + }, + { + value: '127.0.0.1', + type: ObservableTypes.ip4, + }, + { + value: 'https://example.com', + type: ObservableTypes.url, + }, +]; -export { externalServiceMock, executorParams, apiParams }; +export const apiParams = executorParams; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 6fec30803d6d79..dab68bb9d3e9d6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; export const ExternalIncidentServiceConfiguration = { apiUrl: schema.string(), + isLegacy: schema.boolean({ defaultValue: false }), }; export const ExternalIncidentServiceConfigurationSchema = schema.object( @@ -39,6 +40,8 @@ const CommonAttributes = { externalId: schema.nullable(schema.string()), category: schema.nullable(schema.string()), subcategory: schema.nullable(schema.string()), + correlation_id: schema.nullable(schema.string()), + correlation_display: schema.nullable(schema.string()), }; // Schema for ServiceNow Incident Management (ITSM) @@ -56,10 +59,22 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({ export const ExecutorSubActionPushParamsSchemaSIR = schema.object({ incident: schema.object({ ...CommonAttributes, - dest_ip: schema.nullable(schema.string()), - malware_hash: schema.nullable(schema.string()), - malware_url: schema.nullable(schema.string()), - source_ip: schema.nullable(schema.string()), + dest_ip: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + malware_hash: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + malware_url: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + source_ip: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), priority: schema.nullable(schema.string()), }), comments: CommentsSchema, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 37bfb662508a2f..b8499b01e6a02e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -5,15 +5,16 @@ * 2.0. */ -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { createExternalService } from './service'; import * as utils from '../lib/axios_utils'; -import { ExternalService } from './types'; +import { ExternalService, ServiceNowITSMIncident } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../../actions_config.mock'; import { serviceNowCommonFields, serviceNowChoices } from './mocks'; +import { snExternalServiceConfig } from './config'; const logger = loggingSystemMock.create().get() as jest.Mocked; jest.mock('axios'); @@ -28,24 +29,134 @@ jest.mock('../lib/axios_utils', () => { axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; -const patchMock = utils.patch as jest.Mock; const configurationUtilities = actionsConfigMock.create(); -const table = 'incident'; + +const getImportSetAPIResponse = (update = false) => ({ + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + table: 'incident', + display_name: 'number', + display_value: 'INC01', + record_link: 'https://example.com/api/now/table/incident/1', + status: update ? 'updated' : 'inserted', + sys_id: '1', + }, + ], +}); + +const getImportSetAPIError = () => ({ + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + status: 'error', + error_message: 'An error has occurred while importing the incident', + status_message: 'failure', + }, + ], +}); + +const mockApplicationVersion = () => + requestMock.mockImplementationOnce(() => ({ + data: { + result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' }, + }, + })); + +const mockImportIncident = (update: boolean) => + requestMock.mockImplementationOnce(() => ({ + data: getImportSetAPIResponse(update), + })); + +const mockIncidentResponse = (update: boolean) => + requestMock.mockImplementation(() => ({ + data: { + result: { + sys_id: '1', + number: 'INC01', + ...(update + ? { sys_updated_on: '2020-03-10 12:24:20' } + : { sys_created_on: '2020-03-10 12:24:20' }), + }, + }, + })); + +const createIncident = async (service: ExternalService) => { + // Get application version + mockApplicationVersion(); + // Import set api response + mockImportIncident(false); + // Get incident response + mockIncidentResponse(false); + + return await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); +}; + +const updateIncident = async (service: ExternalService) => { + // Get application version + mockApplicationVersion(); + // Import set api response + mockImportIncident(true); + // Get incident response + mockIncidentResponse(true); + + return await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); +}; + +const expectImportedIncident = (update: boolean) => { + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health', + method: 'get', + }); + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident', + method: 'post', + data: { + u_short_description: 'title', + u_description: 'desc', + ...(update ? { elastic_incident_id: '1' } : {}), + }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'get', + }); +}; describe('ServiceNow service', () => { let service: ExternalService; beforeEach(() => { service = createExternalService( - table, { // The trailing slash at the end of the url is intended. // All API calls need to have the trailing slash removed. - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ); }); @@ -57,13 +168,13 @@ describe('ServiceNow service', () => { test('throws without url', () => { expect(() => createExternalService( - table, { config: { apiUrl: null }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -71,13 +182,13 @@ describe('ServiceNow service', () => { test('throws without username', () => { expect(() => createExternalService( - table, { config: { apiUrl: 'test.com' }, secrets: { username: '', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -85,13 +196,13 @@ describe('ServiceNow service', () => { test('throws without password', () => { expect(() => createExternalService( - table, { config: { apiUrl: 'test.com' }, secrets: { username: '', password: undefined }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -116,19 +227,20 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'get', }); }); test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -140,7 +252,8 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', }); }); @@ -166,214 +279,346 @@ describe('ServiceNow service', () => { }); describe('createIncident', () => { - test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); - - const res = await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, + // new connectors + describe('import set table', () => { + test('it creates the incident correctly', async () => { + const res = await createIncident(service); + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); }); - expect(res).toEqual({ - title: 'INC01', - id: '1', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + test('it should call request with correct arguments', async () => { + await createIncident(service); + expect(requestMock).toHaveBeenCalledTimes(3); + expectImportedIncident(false); }); - }); - test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ); - await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, - }); + const res = await createIncident(service); - expect(requestMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident', - method: 'post', - data: { short_description: 'title', description: 'desc' }, - }); - }); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); - test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - 'sn_si_incident', - { - config: { apiUrl: 'https://dev102283.service-now.com/' }, - secrets: { username: 'admin', password: 'admin' }, - }, - logger, - configurationUtilities - ); + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc' }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', + }); - requestMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); + }); - const res = await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, + test('it should throw an error when the application is not installed', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null' + ); }); - expect(requestMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident', - method: 'post', - data: { short_description: 'title', description: 'desc' }, + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); }); - expect(res.url).toEqual( - 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' - ); + test('it should throw an error when there is an import set api error', async () => { + requestMock.mockImplementation(() => ({ data: getImportSetAPIError() })); + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred while importing the incident Reason: unknown' + ); + }); }); - test('it should throw an error', async () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); + // old connectors + describe('table API', () => { + beforeEach(() => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); }); - await expect( - service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, - }) - ).rejects.toThrow( - '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred' - ); - }); + test('it creates the incident correctly', async () => { + mockIncidentResponse(false); + const res = await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); + + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident', + method: 'post', + data: { short_description: 'title', description: 'desc' }, + }); + }); - test('it should throw an error when instance is not alive', async () => { - requestMock.mockImplementation(() => ({ - status: 200, - data: {}, - request: { connection: { servername: 'Developer instance' } }, - })); - await expect(service.getIncident('1')).rejects.toThrow( - 'There is an issue with your Service Now Instance. Please check Developer instance.' - ); - }); - }); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false } + ); - describe('updateIncident', () => { - test('it updates the incident correctly', async () => { - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); + mockIncidentResponse(false); - const res = await service.updateIncident({ - incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, - }); + const res = await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident', + method: 'post', + data: { short_description: 'title', description: 'desc' }, + }); - expect(res).toEqual({ - title: 'INC01', - id: '1', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); }); + }); - test('it should call request with correct arguments', async () => { - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); - - await service.updateIncident({ - incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, + describe('updateIncident', () => { + // new connectors + describe('import set table', () => { + test('it updates the incident correctly', async () => { + const res = await updateIncident(service); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); }); - expect(patchMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', - data: { short_description: 'title', description: 'desc' }, + test('it should call request with correct arguments', async () => { + await updateIncident(service); + expectImportedIncident(true); }); - }); - test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - 'sn_si_incident', - { - config: { apiUrl: 'https://dev102283.service-now.com/' }, - secrets: { username: 'admin', password: 'admin' }, - }, - logger, - configurationUtilities - ); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ); - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); + const res = await updateIncident(service); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); - const res = await service.updateIncident({ - incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', + }); + + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); - expect(patchMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', - data: { short_description: 'title', description: 'desc' }, + test('it should throw an error when the application is not installed', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null' + ); }); - expect(res.url).toEqual( - 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' - ); + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); + + test('it should throw an error when there is an import set api error', async () => { + requestMock.mockImplementation(() => ({ data: getImportSetAPIError() })); + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred while importing the incident Reason: unknown' + ); + }); }); - test('it should throw an error', async () => { - patchMock.mockImplementation(() => { - throw new Error('An error has occurred'); + // old connectors + describe('table API', () => { + beforeEach(() => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); }); - await expect( - service.updateIncident({ + test('it updates the incident correctly', async () => { + mockIncidentResponse(true); + const res = await service.updateIncident({ incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, - }) - ).rejects.toThrow( - '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred' - ); - }); + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); + + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'patch', + data: { short_description: 'title', description: 'desc' }, + }); + }); - test('it creates the comment correctly', async () => { - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '11', number: 'INC011', sys_updated_on: '2020-03-10 12:24:20' } }, - })); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false } + ); - const res = await service.updateIncident({ - incidentId: '1', - comment: 'comment-1', - }); + mockIncidentResponse(false); - expect(res).toEqual({ - title: 'INC011', - id: '11', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=11', - }); - }); + const res = await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); - test('it should throw an error when instance is not alive', async () => { - requestMock.mockImplementation(() => ({ - status: 200, - data: {}, - request: { connection: { servername: 'Developer instance' } }, - })); - await expect(service.getIncident('1')).rejects.toThrow( - 'There is an issue with your Service Now Instance. Please check Developer instance.' - ); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'patch', + data: { short_description: 'title', description: 'desc' }, + }); + + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); + }); }); }); @@ -388,7 +633,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); @@ -402,13 +647,13 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -420,7 +665,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); @@ -456,7 +701,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', }); }); @@ -470,13 +715,13 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -489,7 +734,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', }); }); @@ -513,4 +758,79 @@ describe('ServiceNow service', () => { ); }); }); + + describe('getUrl', () => { + test('it returns the instance url', async () => { + expect(service.getUrl()).toBe('https://example.com'); + }); + }); + + describe('checkInstance', () => { + test('it throws an error if there is no result on data', () => { + const res = { status: 200, data: {} } as AxiosResponse; + expect(() => service.checkInstance(res)).toThrow(); + }); + + test('it does NOT throws an error if the status > 400', () => { + const res = { status: 500, data: {} } as AxiosResponse; + expect(() => service.checkInstance(res)).not.toThrow(); + }); + + test('it shows the servername', () => { + const res = { + status: 200, + data: {}, + request: { connection: { servername: 'https://example.com' } }, + } as AxiosResponse; + expect(() => service.checkInstance(res)).toThrow( + 'There is an issue with your Service Now Instance. Please check https://example.com.' + ); + }); + + describe('getApplicationInformation', () => { + test('it returns the application information', async () => { + mockApplicationVersion(); + const res = await service.getApplicationInformation(); + expect(res).toEqual({ + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + await expect(service.getApplicationInformation()).rejects.toThrow( + '[Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown' + ); + }); + }); + + describe('checkIfApplicationIsInstalled', () => { + test('it logs the application information', async () => { + mockApplicationVersion(); + await service.checkIfApplicationIsInstalled(); + expect(logger.debug).toHaveBeenCalledWith( + 'Create incident: Application scope: x_elas2_inc_int: Application version1.0.0' + ); + }); + + test('it does not log if useOldApi = true', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); + await service.checkIfApplicationIsInstalled(); + expect(requestMock).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 07ed9edc94d394..cb030c7bb69336 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -7,28 +7,35 @@ import axios, { AxiosResponse } from 'axios'; -import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from './types'; +import { + ExternalServiceCredentials, + ExternalService, + ExternalServiceParamsCreate, + ExternalServiceParamsUpdate, + ImportSetApiResponse, + ImportSetApiResponseError, + ServiceNowIncident, + GetApplicationInfoResponse, + SNProductsConfigValue, + ServiceFactory, +} from './types'; import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; -import { - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ResponseError, -} from './types'; -import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios_utils'; +import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types'; +import { request } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { createServiceError, getPushedDate, prepareIncident } from './utils'; -const API_VERSION = 'v2'; -const SYS_DICTIONARY = `api/now/${API_VERSION}/table/sys_dictionary`; +export const SYS_DICTIONARY_ENDPOINT = `api/now/table/sys_dictionary`; -export const createExternalService = ( - table: string, +export const createExternalService: ServiceFactory = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + { table, importSetTable, useImportAPI, appScope }: SNProductsConfigValue ): ExternalService => { - const { apiUrl: url } = config as ServiceNowPublicConfigurationType; + const { apiUrl: url, isLegacy } = config as ServiceNowPublicConfigurationType; const { username, password } = secrets as ServiceNowSecretConfigurationType; if (!url || !username || !password) { @@ -36,13 +43,26 @@ export const createExternalService = ( } const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; - const incidentUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/${table}`; - const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`; - const choicesUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/sys_choice`; + const importSetTableUrl = `${urlWithoutTrailingSlash}/api/now/import/${importSetTable}`; + const tableApiIncidentUrl = `${urlWithoutTrailingSlash}/api/now/v2/table/${table}`; + const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY_ENDPOINT}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`; + const choicesUrl = `${urlWithoutTrailingSlash}/api/now/table/sys_choice`; + /** + * Need to be set the same at: + * x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts + */ + const getVersionUrl = () => `${urlWithoutTrailingSlash}/api/${appScope}/elastic_api/health`; + const axiosInstance = axios.create({ auth: { username, password }, }); + const useOldApi = !useImportAPI || isLegacy; + + const getCreateIncidentUrl = () => (useOldApi ? tableApiIncidentUrl : importSetTableUrl); + const getUpdateIncidentUrl = (incidentId: string) => + useOldApi ? `${tableApiIncidentUrl}/${incidentId}` : importSetTableUrl; + const getIncidentViewURL = (id: string) => { // Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html return `${urlWithoutTrailingSlash}/nav_to.do?uri=${table}.do?sys_id=${id}`; @@ -57,7 +77,7 @@ export const createExternalService = ( }; const checkInstance = (res: AxiosResponse) => { - if (res.status === 200 && res.data.result == null) { + if (res.status >= 200 && res.status < 400 && res.data.result == null) { throw new Error( `There is an issue with your Service Now Instance. Please check ${ res.request?.connection?.servername ?? '' @@ -66,34 +86,70 @@ export const createExternalService = ( } }; - const createErrorMessage = (errorResponse: ResponseError): string => { - if (errorResponse == null) { - return ''; + const isImportSetApiResponseAnError = ( + data: ImportSetApiResponse['result'][0] + ): data is ImportSetApiResponseError['result'][0] => data.status === 'error'; + + const throwIfImportSetApiResponseIsAnError = (res: ImportSetApiResponse) => { + if (res.result.length === 0) { + throw new Error('Unexpected result'); } - const { error } = errorResponse; - return error != null ? `${error?.message}: ${error?.detail}` : ''; + const data = res.result[0]; + + // Create ResponseError message? + if (isImportSetApiResponseAnError(data)) { + throw new Error(data.error_message); + } }; - const getIncident = async (id: string) => { + /** + * Gets the Elastic SN Application information including the current version. + * It should not be used on legacy connectors. + */ + const getApplicationInformation = async (): Promise => { try { const res = await request({ axios: axiosInstance, - url: `${incidentUrl}/${id}`, + url: getVersionUrl(), logger, configurationUtilities, + method: 'get', }); + checkInstance(res); + return { ...res.data.result }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get incident with id ${id}. Error: ${ - error.message - } Reason: ${createErrorMessage(error.response?.data)}` - ) - ); + throw createServiceError(error, 'Unable to get application version'); + } + }; + + const logApplicationInfo = (scope: string, version: string) => + logger.debug(`Create incident: Application scope: ${scope}: Application version${version}`); + + const checkIfApplicationIsInstalled = async () => { + if (!useOldApi) { + const { version, scope } = await getApplicationInformation(); + logApplicationInfo(scope, version); + } + }; + + const getIncident = async (id: string): Promise => { + try { + const res = await request({ + axios: axiosInstance, + url: `${tableApiIncidentUrl}/${id}`, + logger, + configurationUtilities, + method: 'get', + }); + + checkInstance(res); + + return { ...res.data.result }; + } catch (error) { + throw createServiceError(error, `Unable to get incident with id ${id}`); } }; @@ -101,7 +157,7 @@ export const createExternalService = ( try { const res = await request({ axios: axiosInstance, - url: incidentUrl, + url: tableApiIncidentUrl, logger, params, configurationUtilities, @@ -109,71 +165,80 @@ export const createExternalService = ( checkInstance(res); return res.data.result.length > 0 ? { ...res.data.result } : undefined; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to find incidents by query. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to find incidents by query'); } }; - const createIncident = async ({ incident }: ExternalServiceParams) => { + const getUrl = () => urlWithoutTrailingSlash; + + const createIncident = async ({ incident }: ExternalServiceParamsCreate) => { try { + await checkIfApplicationIsInstalled(); + const res = await request({ axios: axiosInstance, - url: `${incidentUrl}`, + url: getCreateIncidentUrl(), logger, method: 'post', - data: { ...(incident as Record) }, + data: prepareIncident(useOldApi, incident), configurationUtilities, }); + checkInstance(res); + + if (!useOldApi) { + throwIfImportSetApiResponseIsAnError(res.data); + } + + const incidentId = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id; + const insertedIncident = await getIncident(incidentId); + return { - title: res.data.result.number, - id: res.data.result.sys_id, - pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), - url: getIncidentViewURL(res.data.result.sys_id), + title: insertedIncident.number, + id: insertedIncident.sys_id, + pushedDate: getPushedDate(insertedIncident.sys_created_on), + url: getIncidentViewURL(insertedIncident.sys_id), }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to create incident. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to create incident'); } }; - const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + const updateIncident = async ({ incidentId, incident }: ExternalServiceParamsUpdate) => { try { - const res = await patch({ + await checkIfApplicationIsInstalled(); + + const res = await request({ axios: axiosInstance, - url: `${incidentUrl}/${incidentId}`, + url: getUpdateIncidentUrl(incidentId), + // Import Set API supports only POST. + method: useOldApi ? 'patch' : 'post', logger, - data: { ...(incident as Record) }, + data: { + ...prepareIncident(useOldApi, incident), + // elastic_incident_id is used to update the incident when using the Import Set API. + ...(useOldApi ? {} : { elastic_incident_id: incidentId }), + }, configurationUtilities, }); + checkInstance(res); + + if (!useOldApi) { + throwIfImportSetApiResponseIsAnError(res.data); + } + + const id = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id; + const updatedIncident = await getIncident(id); + return { - title: res.data.result.number, - id: res.data.result.sys_id, - pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), - url: getIncidentViewURL(res.data.result.sys_id), + title: updatedIncident.number, + id: updatedIncident.sys_id, + pushedDate: getPushedDate(updatedIncident.sys_updated_on), + url: getIncidentViewURL(updatedIncident.sys_id), }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to update incident with id ${incidentId}. Error: ${ - error.message - } Reason: ${createErrorMessage(error.response?.data)}` - ) - ); + throw createServiceError(error, `Unable to update incident with id ${incidentId}`); } }; @@ -185,17 +250,12 @@ export const createExternalService = ( logger, configurationUtilities, }); + checkInstance(res); + return res.data.result.length > 0 ? res.data.result : []; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get fields. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to get fields'); } }; @@ -210,14 +270,7 @@ export const createExternalService = ( checkInstance(res); return res.data.result; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get choices. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to get choices'); } }; @@ -228,5 +281,9 @@ export const createExternalService = ( getIncident, updateIncident, getChoices, + getUrl, + checkInstance, + getApplicationInformation, + checkIfApplicationIsInstalled, }; }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts new file mode 100644 index 00000000000000..0fc94b6287abdf --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import axios from 'axios'; + +import { createExternalServiceSIR } from './service_sir'; +import * as utils from '../lib/axios_utils'; +import { ExternalServiceSIR } from './types'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { observables } from './mocks'; +import { snExternalServiceConfig } from './config'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +jest.mock('axios'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + patch: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); + +const mockApplicationVersion = () => + requestMock.mockImplementationOnce(() => ({ + data: { + result: { name: 'Elastic', scope: 'x_elas2_sir_int', version: '1.0.0' }, + }, + })); + +const getAddObservablesResponse = () => [ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + observable_sys_id: '1', + }, + { + value: '127.0.0.1', + observable_sys_id: '2', + }, + { + value: 'https://example.com', + observable_sys_id: '3', + }, +]; + +const mockAddObservablesResponse = (single: boolean) => { + const res = getAddObservablesResponse(); + requestMock.mockImplementation(() => ({ + data: { + result: single ? res[0] : res, + }, + })); +}; + +const expectAddObservables = (single: boolean) => { + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); + + const url = single + ? 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables' + : 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables/bulk'; + + const data = single ? observables[0] : observables; + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url, + method: 'post', + data, + }); +}; + +describe('ServiceNow SIR service', () => { + let service: ExternalServiceSIR; + + beforeEach(() => { + service = createExternalServiceSIR( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ) as ExternalServiceSIR; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('bulkAddObservableToIncident', () => { + test('it adds multiple observables correctly', async () => { + mockApplicationVersion(); + mockAddObservablesResponse(false); + + const res = await service.bulkAddObservableToIncident(observables, 'incident-1'); + expect(res).toEqual(getAddObservablesResponse()); + expectAddObservables(false); + }); + + test('it adds a single observable correctly', async () => { + mockApplicationVersion(); + mockAddObservablesResponse(true); + + const res = await service.addObservableToIncident(observables[0], 'incident-1'); + expect(res).toEqual(getAddObservablesResponse()[0]); + expectAddObservables(true); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts new file mode 100644 index 00000000000000..fc8d8cc555bc8d --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts @@ -0,0 +1,104 @@ +/* + * 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 axios from 'axios'; + +import { + ExternalServiceCredentials, + SNProductsConfigValue, + Observable, + ExternalServiceSIR, + ObservableResponse, + ServiceFactory, +} from './types'; + +import { Logger } from '../../../../../../src/core/server'; +import { ServiceNowSecretConfigurationType } from './types'; +import { request } from '../lib/axios_utils'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { createExternalService } from './service'; +import { createServiceError } from './utils'; + +const getAddObservableToIncidentURL = (url: string, incidentID: string) => + `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables`; + +const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) => + `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`; + +export const createExternalServiceSIR: ServiceFactory = ( + credentials: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities, + serviceConfig: SNProductsConfigValue +): ExternalServiceSIR => { + const snService = createExternalService( + credentials, + logger, + configurationUtilities, + serviceConfig + ); + + const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType; + const axiosInstance = axios.create({ + auth: { username, password }, + }); + + const _addObservable = async (data: Observable | Observable[], url: string) => { + snService.checkIfApplicationIsInstalled(); + + const res = await request({ + axios: axiosInstance, + url, + logger, + method: 'post', + data, + configurationUtilities, + }); + + snService.checkInstance(res); + return res.data.result; + }; + + const addObservableToIncident = async ( + observable: Observable, + incidentID: string + ): Promise => { + try { + return await _addObservable( + observable, + getAddObservableToIncidentURL(snService.getUrl(), incidentID) + ); + } catch (error) { + throw createServiceError( + error, + `Unable to add observable to security incident with id ${incidentID}` + ); + } + }; + + const bulkAddObservableToIncident = async ( + observables: Observable[], + incidentID: string + ): Promise => { + try { + return await _addObservable( + observables, + getBulkAddObservableToIncidentURL(snService.getUrl(), incidentID) + ); + } catch (error) { + throw createServiceError( + error, + `Unable to add observables to security incident with id ${incidentID}` + ); + } + }; + return { + ...snService, + addObservableToIncident, + bulkAddObservableToIncident, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 50631cf289a73d..ecca1e55e0fec6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -7,6 +7,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { AxiosError, AxiosResponse } from 'axios'; import { TypeOf } from '@kbn/config-schema'; import { ExecutorParamsSchemaITSM, @@ -78,15 +79,29 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse { comments?: ExternalServiceCommentResponse[]; } -export type ExternalServiceParams = Record; +export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident; +export type PartialIncident = Partial; + +export interface ExternalServiceParamsCreate { + incident: Incident & Record; +} + +export interface ExternalServiceParamsUpdate { + incidentId: string; + incident: PartialIncident & Record; +} export interface ExternalService { getChoices: (fields: string[]) => Promise; - getIncident: (id: string) => Promise; + getIncident: (id: string) => Promise; getFields: () => Promise; - createIncident: (params: ExternalServiceParams) => Promise; - updateIncident: (params: ExternalServiceParams) => Promise; - findIncidents: (params?: Record) => Promise; + createIncident: (params: ExternalServiceParamsCreate) => Promise; + updateIncident: (params: ExternalServiceParamsUpdate) => Promise; + findIncidents: (params?: Record) => Promise; + getUrl: () => string; + checkInstance: (res: AxiosResponse) => void; + getApplicationInformation: () => Promise; + checkIfApplicationIsInstalled: () => Promise; } export type PushToServiceApiParams = ExecutorSubActionPushParams; @@ -115,10 +130,9 @@ export type ServiceNowSIRIncident = Omit< 'externalId' >; -export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident; - export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { params: PushToServiceApiParams; + config: Record; secrets: Record; logger: Logger; commentFieldKey: string; @@ -158,12 +172,20 @@ export interface GetChoicesHandlerArgs { params: ExecutorSubActionGetChoicesParams; } -export interface ExternalServiceApi { +export interface ServiceNowIncident { + sys_id: string; + number: string; + sys_created_on: string; + sys_updated_on: string; + [x: string]: unknown; +} + +export interface ExternalServiceAPI { getChoices: (args: GetChoicesHandlerArgs) => Promise; getFields: (args: GetCommonFieldsHandlerArgs) => Promise; handshake: (args: HandshakeApiHandlerArgs) => Promise; pushToService: (args: PushToServiceApiHandlerArgs) => Promise; - getIncident: (args: GetIncidentApiHandlerArgs) => Promise; + getIncident: (args: GetIncidentApiHandlerArgs) => Promise; } export interface ExternalServiceCommentResponse { @@ -173,10 +195,90 @@ export interface ExternalServiceCommentResponse { } type TypeNullOrUndefined = T | null | undefined; -export interface ResponseError { + +export interface ServiceNowError { error: TypeNullOrUndefined<{ message: TypeNullOrUndefined; detail: TypeNullOrUndefined; }>; status: TypeNullOrUndefined; } + +export type ResponseError = AxiosError; + +export interface ImportSetApiResponseSuccess { + import_set: string; + staging_table: string; + result: Array<{ + display_name: string; + display_value: string; + record_link: string; + status: string; + sys_id: string; + table: string; + transform_map: string; + }>; +} + +export interface ImportSetApiResponseError { + import_set: string; + staging_table: string; + result: Array<{ + error_message: string; + status_message: string; + status: string; + transform_map: string; + }>; +} + +export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError; +export interface GetApplicationInfoResponse { + id: string; + name: string; + scope: string; + version: string; +} + +export interface SNProductsConfigValue { + table: string; + appScope: string; + useImportAPI: boolean; + importSetTable: string; + commentFieldKey: string; +} + +export type SNProductsConfig = Record; + +export enum ObservableTypes { + ip4 = 'ipv4-addr', + url = 'URL', + sha256 = 'SHA256', +} + +export interface Observable { + value: string; + type: ObservableTypes; +} + +export interface ObservableResponse { + value: string; + observable_sys_id: ObservableTypes; +} + +export interface ExternalServiceSIR extends ExternalService { + addObservableToIncident: ( + observable: Observable, + incidentID: string + ) => Promise; + bulkAddObservableToIncident: ( + observables: Observable[], + incidentID: string + ) => Promise; +} + +export type ServiceFactory = ( + credentials: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities, + serviceConfig: SNProductsConfigValue +) => ExternalServiceSIR | ExternalService; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts new file mode 100644 index 00000000000000..87f27da6d213f4 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts @@ -0,0 +1,84 @@ +/* + * 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 { AxiosError } from 'axios'; +import { prepareIncident, createServiceError, getPushedDate } from './utils'; + +/** + * The purpose of this test is to + * prevent developers from accidentally + * change important configuration values + * such as the scope or the import set table + * of our ServiceNow application + */ + +describe('utils', () => { + describe('prepareIncident', () => { + test('it prepares the incident correctly when useOldApi=false', async () => { + const incident = { short_description: 'title', description: 'desc' }; + const newIncident = prepareIncident(false, incident); + expect(newIncident).toEqual({ u_short_description: 'title', u_description: 'desc' }); + }); + + test('it prepares the incident correctly when useOldApi=true', async () => { + const incident = { short_description: 'title', description: 'desc' }; + const newIncident = prepareIncident(true, incident); + expect(newIncident).toEqual(incident); + }); + }); + + describe('createServiceError', () => { + test('it creates an error when the response is null', async () => { + const error = new Error('An error occurred'); + // @ts-expect-error + expect(createServiceError(error, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: errorResponse was null' + ); + }); + + test('it creates an error with response correctly', async () => { + const axiosError = { + message: 'An error occurred', + response: { data: { error: { message: 'Denied', detail: 'no access' } } }, + } as AxiosError; + + expect(createServiceError(axiosError, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: Denied: no access' + ); + }); + + test('it creates an error correctly when the ServiceNow error is null', async () => { + const axiosError = { + message: 'An error occurred', + response: { data: { error: null } }, + } as AxiosError; + + expect(createServiceError(axiosError, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: no error in error response' + ); + }); + }); + + describe('getPushedDate', () => { + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2021-10-04 11:15:06 GMT')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + test('it formats the date correctly if timestamp is provided', async () => { + expect(getPushedDate('2021-10-04 11:15:06')).toBe('2021-10-04T11:15:06.000Z'); + }); + + test('it formats the date correctly if timestamp is not provided', async () => { + expect(getPushedDate()).toBe('2021-10-04T11:15:06.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts new file mode 100644 index 00000000000000..5b7ca99ffc709c --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -0,0 +1,46 @@ +/* + * 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 { Incident, PartialIncident, ResponseError, ServiceNowError } from './types'; +import { FIELD_PREFIX } from './config'; +import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils'; +import * as i18n from './translations'; + +export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident => + useOldApi + ? incident + : Object.entries(incident).reduce( + (acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }), + {} as Incident + ); + +const createErrorMessage = (errorResponse?: ServiceNowError): string => { + if (errorResponse == null) { + return 'unknown: errorResponse was null'; + } + + const { error } = errorResponse; + return error != null + ? `${error?.message}: ${error?.detail}` + : 'unknown: no error in error response'; +}; + +export const createServiceError = (error: ResponseError, message: string) => + new Error( + getErrorMessage( + i18n.SERVICENOW, + `${message}. Error: ${error.message} Reason: ${createErrorMessage(error.response?.data)}` + ) + ); + +export const getPushedDate = (timestamp?: string) => { + if (timestamp != null) { + return new Date(addTimeZoneToDate(timestamp)).toISOString(); + } + + return new Date().toISOString(); +}; diff --git a/x-pack/plugins/actions/server/constants/connectors.ts b/x-pack/plugins/actions/server/constants/connectors.ts new file mode 100644 index 00000000000000..f20d499716cf0f --- /dev/null +++ b/x-pack/plugins/actions/server/constants/connectors.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// TODO: Remove when Elastic for ITSM is published. +export const ENABLE_NEW_SN_ITSM_CONNECTOR = true; + +// TODO: Remove when Elastic for Security Operations is published. +export const ENABLE_NEW_SN_SIR_CONNECTOR = true; diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts index c094109a43d97e..9f8e62c77e3a73 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts @@ -165,6 +165,47 @@ describe('successful migrations', () => { }); expect(migratedAction).toEqual(action); }); + + test('set isLegacy config property for .servicenow', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockDataForServiceNow(); + const migratedAction = migration716(action, context); + + expect(migratedAction).toEqual({ + ...action, + attributes: { + ...action.attributes, + config: { + apiUrl: 'https://example.com', + isLegacy: true, + }, + }, + }); + }); + + test('set isLegacy config property for .servicenow-sir', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockDataForServiceNow({ actionTypeId: '.servicenow-sir' }); + const migratedAction = migration716(action, context); + + expect(migratedAction).toEqual({ + ...action, + attributes: { + ...action.attributes, + config: { + apiUrl: 'https://example.com', + isLegacy: true, + }, + }, + }); + }); + + test('it does not set isLegacy config for other connectors', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockData(); + const migratedAction = migration716(action, context); + expect(migratedAction).toEqual(action); + }); }); describe('8.0.0', () => { @@ -306,3 +347,19 @@ function getMockData( type: 'action', }; } + +function getMockDataForServiceNow( + overwrites: Record = {} +): SavedObjectUnsanitizedDoc> { + return { + attributes: { + name: 'abc', + actionTypeId: '.servicenow', + config: { apiUrl: 'https://example.com' }, + secrets: { user: 'test', password: '123' }, + ...overwrites, + }, + id: uuid.v4(), + type: 'action', + }; +} diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts index e75f3eb41f2df5..688839eb898581 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts @@ -59,13 +59,16 @@ export function getActionsMigrations( const migrationActionsFourteen = createEsoMigration( encryptedSavedObjects, (doc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(addisMissingSecretsField) + pipeMigrations(addIsMissingSecretsField) ); - const migrationEmailActionsSixteen = createEsoMigration( + const migrationActionsSixteen = createEsoMigration( encryptedSavedObjects, - (doc): doc is SavedObjectUnsanitizedDoc => doc.attributes.actionTypeId === '.email', - pipeMigrations(setServiceConfigIfNotSet) + (doc): doc is SavedObjectUnsanitizedDoc => + doc.attributes.actionTypeId === '.servicenow' || + doc.attributes.actionTypeId === '.servicenow-sir' || + doc.attributes.actionTypeId === '.email', + pipeMigrations(markOldServiceNowITSMConnectorAsLegacy, setServiceConfigIfNotSet) ); const migrationActions800 = createEsoMigration( @@ -79,7 +82,7 @@ export function getActionsMigrations( '7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'), '7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'), - '7.16.0': executeMigrationWithErrorHandling(migrationEmailActionsSixteen, '7.16.0'), + '7.16.0': executeMigrationWithErrorHandling(migrationActionsSixteen, '7.16.0'), '8.0.0': executeMigrationWithErrorHandling(migrationActions800, '8.0.0'), }; } @@ -182,7 +185,7 @@ const setServiceConfigIfNotSet = ( }; }; -const addisMissingSecretsField = ( +const addIsMissingSecretsField = ( doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc => { return { @@ -194,6 +197,28 @@ const addisMissingSecretsField = ( }; }; +const markOldServiceNowITSMConnectorAsLegacy = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { + if ( + doc.attributes.actionTypeId !== '.servicenow' && + doc.attributes.actionTypeId !== '.servicenow-sir' + ) { + return doc; + } + + return { + ...doc, + attributes: { + ...doc.attributes, + config: { + ...doc.attributes.config, + isLegacy: true, + }, + }, + }; +}; + function pipeMigrations(...migrations: ActionMigration[]): ActionMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); diff --git a/x-pack/plugins/apm/dev_docs/apm_queries.md b/x-pack/plugins/apm/dev_docs/apm_queries.md index 8508e5a173c858..0fbcd4fc1c8a86 100644 --- a/x-pack/plugins/apm/dev_docs/apm_queries.md +++ b/x-pack/plugins/apm/dev_docs/apm_queries.md @@ -1,7 +1,17 @@ -# Data model +### Table of Contents + - [Transactions](#transactions) + - [System metrics](#system-metrics) + - [Transaction breakdown metrics](#transaction-breakdown-metrics) + - [Span breakdown metrics](#span-breakdown-metrics) + - [Service destination metrics](#service-destination-metrics) + - [Common filters](#common-filters) + +--- + +### Data model Elastic APM agents capture different types of information from within their instrumented applications. These are known as events, and can be spans, transactions, errors, or metrics. You can find more information [here](https://www.elastic.co/guide/en/apm/get-started/current/apm-data-model.html). -# Running examples +### Running examples You can run the example queries on the [edge cluster](https://edge-oblt.elastic.dev/) or any another cluster that contains APM data. # Transactions @@ -307,7 +317,7 @@ The above example is overly simplified. In reality [we do a bit more](https://gi -# Transaction breakdown metrics (`transaction_breakdown`) +# Transaction breakdown metrics A pre-aggregations of transaction documents where `transaction.breakdown.count` is the number of original transactions. @@ -327,7 +337,7 @@ Noteworthy fields: `transaction.name`, `transaction.type` } ``` -# Span breakdown metrics (`span_breakdown`) +# Span breakdown metrics A pre-aggregations of span documents where `span.self_time.count` is the number of original spans. Measures the "self-time" for a span type, and optional subtype, within a transaction group. @@ -482,7 +492,7 @@ GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 } ``` -## Common filters +# Common filters Most Elasticsearch queries will need to have one or more filters. There are a couple of reasons for adding filters: diff --git a/x-pack/plugins/apm/dev_docs/testing.md b/x-pack/plugins/apm/dev_docs/testing.md index 4d0edc27fe6442..ba48e7e229e273 100644 --- a/x-pack/plugins/apm/dev_docs/testing.md +++ b/x-pack/plugins/apm/dev_docs/testing.md @@ -64,3 +64,13 @@ node scripts/functional_test_runner --config x-pack/test/functional/config.js -- APM tests are located in `x-pack/test/functional/apps/apm`. For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme) diff --git a/x-pack/plugins/apm/scripts/test/README.md b/x-pack/plugins/apm/scripts/test/README.md + + +## Storybook + +### Start +``` +yarn storybook apm +``` + +All files with a .stories.tsx extension will be loaded. You can access the development environment at http://localhost:9001. diff --git a/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx b/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx index 8a54c76df0f691..ee6a58b0dbb76c 100644 --- a/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx @@ -96,7 +96,7 @@ export function ChartPreview({ position={Position.Left} tickFormat={yTickFormat} ticks={5} - domain={{ max: yMax }} + domain={{ max: yMax, min: NaN }} /> { + const onBrushEnd = ({ x }: XYBrushEvent) => { if (!x) { return; } @@ -99,7 +100,7 @@ export function PageLoadDistChart({ diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx index a2a92b7e16f8ea..12fa1c955ccc8f 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx @@ -15,12 +15,6 @@ import props from './__fixtures__/props.json'; import { MemoryRouter } from 'react-router-dom'; import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { - return { - htmlIdGenerator: () => () => `generated-id`, - }; -}); - describe('ErrorGroupOverview -> List', () => { beforeAll(() => { mockMoment(); diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap index 890c692096a664..c8c7bf82dff047 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap @@ -56,7 +56,7 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = `
List should render with data 1`] = `
List should render with data 1`] = ` className="euiPagination__item" >