diff --git a/.eslintignore b/.eslintignore index 63cd01d6e90db..f757ed9a1bf98 100644 --- a/.eslintignore +++ b/.eslintignore @@ -30,8 +30,6 @@ snapshots.js # package overrides /packages/elastic-eslint-config-kibana -/packages/kbn-interpreter/src/common/lib/grammar.js -/packages/kbn-tinymath/src/grammar.js /packages/kbn-plugin-generator/template /packages/kbn-pm/dist /packages/kbn-test/src/functional_test_runner/__tests__/fixtures/ diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index c211751c09b49..e8b950a696f55 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -80,6 +80,7 @@ yarn kbn watch-bazel - @kbn/eslint-plugin-eslint - @kbn/expect - @kbn/i18n +- @kbn/interpreter - @kbn/io-ts-utils - @kbn/legacy-logging - @kbn/logging @@ -100,8 +101,10 @@ yarn kbn watch-bazel - @kbn/server-http-tools - @kbn/server-route-repository - @kbn/std +- @kbn/storybook - @kbn/telemetry-utils - @kbn/tinymath +- @kbn/ui-framework - @kbn/ui-shared-deps - @kbn/utility-types - @kbn/utils 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 9930ab7319f65..d3d76079cdc2a 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 @@ -185,5 +185,18 @@ readonly links: { readonly plugins: Record; readonly snapshotRestore: Record; readonly ingest: Record; + 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; + }>; }; ``` 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 ab8cdea5e4d86..34279cef198bf 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,5 @@ 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 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 auditbeat: {
readonly base: 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 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 indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: 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: Record<string, 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>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
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 auditbeat: {
readonly base: 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 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 indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: 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: Record<string, 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;
}>;
} | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.getresolvedtimerange.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.getresolvedtimerange.md new file mode 100644 index 0000000000000..2af44037292a2 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.getresolvedtimerange.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [getResolvedTimeRange](./kibana-plugin-plugins-data-public.aggconfigs.getresolvedtimerange.md) + +## AggConfigs.getResolvedTimeRange() method + +Returns the current time range as moment instance (date math will get resolved using the current "now" value or system time if not set) + +Signature: + +```typescript +getResolvedTimeRange(): import("../..").TimeRangeBounds | undefined; +``` +Returns: + +`import("../..").TimeRangeBounds | undefined` + +Current time range as resolved date. + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md index 45333b6767cac..9e671675b0b29 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md @@ -42,6 +42,7 @@ export declare class AggConfigs | [getAll()](./kibana-plugin-plugins-data-public.aggconfigs.getall.md) | | | | [getRequestAggById(id)](./kibana-plugin-plugins-data-public.aggconfigs.getrequestaggbyid.md) | | | | [getRequestAggs()](./kibana-plugin-plugins-data-public.aggconfigs.getrequestaggs.md) | | | +| [getResolvedTimeRange()](./kibana-plugin-plugins-data-public.aggconfigs.getresolvedtimerange.md) | | Returns the current time range as moment instance (date math will get resolved using the current "now" value or system time if not set) | | [getResponseAggById(id)](./kibana-plugin-plugins-data-public.aggconfigs.getresponseaggbyid.md) | | Find a response agg by it's id. This may be an agg in the aggConfigs, or one created specifically for a response value | | [getResponseAggs()](./kibana-plugin-plugins-data-public.aggconfigs.getresponseaggs.md) | | Gets the AggConfigs (and possibly ResponseAggConfigs) that represent the values that will be produced when all aggs are run.With multi-value metric aggs it is possible for a single agg request to result in multiple agg values, which is why the length of a vis' responseValuesAggs may be different than the vis' aggs {array\[AggConfig\]} | | [getSearchSourceTimeFilter(forceNow)](./kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md) | | | diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index ddb906f390a2d..c3c29adcea18f 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -325,6 +325,9 @@ The time interval policy will rotate the log file every given interval of time. When `includeElasticMapsService` is turned off, only the vector layers configured by <> and the tile layer configured by <> are available in <>. *Default: `true`* +| `map.emsUrl:` + | Specifies the URL of a self hosted <> + | `map.proxyElasticMapsServiceInMaps:` | Set to `true` to proxy all <> Elastic Maps Service requests through the {kib} server. *Default: `false`* diff --git a/docs/user/alerting/alerting-getting-started.asciidoc b/docs/user/alerting/alerting-getting-started.asciidoc index 8c17f8ec93b96..b699c56ebd944 100644 --- a/docs/user/alerting/alerting-getting-started.asciidoc +++ b/docs/user/alerting/alerting-getting-started.asciidoc @@ -136,9 +136,4 @@ Functionally, {kib} alerting differs in that: At a higher level, {kib} alerting allows rich integrations across use cases like <>, <>, <>, and <>. Pre-packaged *rule types* simplify setup and hide the details of complex, domain-specific detections, while providing a consistent interface across {kib}. -[float] -[[alerting-setup-prerequisites]] -== Prerequisites -<> - -- \ No newline at end of file diff --git a/docs/user/alerting/alerting-setup.asciidoc b/docs/user/alerting/alerting-setup.asciidoc index 39f1af0030e0a..2ae5160069f0a 100644 --- a/docs/user/alerting/alerting-setup.asciidoc +++ b/docs/user/alerting/alerting-setup.asciidoc @@ -1,8 +1,8 @@ [role="xpack"] [[alerting-setup]] -== Alerting Setup +== Alerting Set up ++++ -Setup +Set up ++++ The Alerting feature is automatically enabled in {kib}, but might require some additional configuration. diff --git a/docs/user/alerting/alerting-troubleshooting.asciidoc b/docs/user/alerting/alerting-troubleshooting.asciidoc index b7b0c749dfe14..08655508b3cba 100644 --- a/docs/user/alerting/alerting-troubleshooting.asciidoc +++ b/docs/user/alerting/alerting-troubleshooting.asciidoc @@ -12,6 +12,32 @@ If your problem isn’t described here, please review open issues in the followi Have a question? Contact us in the https://discuss.elastic.co/[discuss forum]. +[float] +[[rule-cannot-decrypt-api-key]] +=== Rule cannot decrypt apiKey + +*Problem*: + +The rule fails to execute and has an `Unable to decrypt attribute "apiKey"` error. + +*Solution*: + +This error happens when the `xpack.encryptedSavedObjects.encryptionKey` value used to create the rule does not match the value used during rule execution. Depending on the scenario, there are different ways to solve this problem: + +[cols="2*<"] +|=== + +| If the value in `xpack.encryptedSavedObjects.encryptionKey` was manually changed, and the previous encryption key is still known. +| Ensure any previous encryption key is included in the keys used for <>. + +| If another {kib} instance with a different encryption key connects to the cluster. +| The other {kib} instance might be trying to run the rule using a different encryption key than what the rule was created with. Ensure the encryption keys among all the {kib} instances are the same, and setting <> for previously used encryption keys. + +| If other scenarios don't apply. +| Generate a new API key for the rule by disabling then enabling the rule. + +|=== + [float] [[rules-small-check-interval-run-late]] === Rules with small check intervals run late @@ -29,7 +55,6 @@ Either tweak the <> or increa For more details, see <>. - [float] [[scheduled-rules-run-late]] === Rules run late diff --git a/docs/user/alerting/defining-rules.asciidoc b/docs/user/alerting/defining-rules.asciidoc deleted file mode 100644 index 686a7bbc8a37b..0000000000000 --- a/docs/user/alerting/defining-rules.asciidoc +++ /dev/null @@ -1,11 +0,0 @@ -[role="xpack"] -[[defining-alerts]] -== Defining rules - -This content has been moved to <>. - -[float] -[[defining-alerts-general-details]] -==== General rule details - -This content has been moved to <>. \ No newline at end of file diff --git a/docs/user/alerting/index.asciidoc b/docs/user/alerting/index.asciidoc index 9ab6a2dc46ebf..957d99a54ebaa 100644 --- a/docs/user/alerting/index.asciidoc +++ b/docs/user/alerting/index.asciidoc @@ -1,7 +1,5 @@ include::alerting-getting-started.asciidoc[] include::alerting-setup.asciidoc[] include::create-and-manage-rules.asciidoc[] -include::defining-rules.asciidoc[] -include::rule-management.asciidoc[] include::rule-types.asciidoc[] include::alerting-troubleshooting.asciidoc[] diff --git a/docs/user/alerting/rule-management.asciidoc b/docs/user/alerting/rule-management.asciidoc deleted file mode 100644 index d6349a60e08eb..0000000000000 --- a/docs/user/alerting/rule-management.asciidoc +++ /dev/null @@ -1,5 +0,0 @@ -[role="xpack"] -[[alert-management]] -== Managing rules - -This content has been moved to <>. \ No newline at end of file diff --git a/docs/user/alerting/rule-types.asciidoc b/docs/user/alerting/rule-types.asciidoc index bb840014fe80f..f7f57d2f845a0 100644 --- a/docs/user/alerting/rule-types.asciidoc +++ b/docs/user/alerting/rule-types.asciidoc @@ -15,7 +15,7 @@ see {subscriptions}[the subscription page]. [[stack-rules]] === Stack rules -<> are built into {kib}. To access the *Stack Rules* feature and create and edit rules, users require the `all` privilege. See <> for more information. +<> are built into {kib}. To access the *Stack Rules* feature and create and edit rules, users require the `all` privilege. See <> for more information. [cols="2*<"] |=== diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index 0eb4b43466ff9..84c33db31d575 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -112,7 +112,7 @@ The following panel types support drilldowns. ^| X ^| X -| TSVB +| TSVB (only for time series visualizations) ^| X ^| X diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index cc384ec041a9d..6829e129cd3b6 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -50,6 +50,11 @@ To learn more, read about https://vega.github.io/vega/docs/specification/#autosize[autosize] in the Vega documentation. +WARNING: Autosize in Vega-Lite has https://vega.github.io/vega-lite/docs/size.html#limitations[several limitations] +that can result in a warning like `Autosize "fit" only works for single views and layered views.` +The recommended fix for this warning is to convert your spec to Vega using the <> +`VEGA_DEBUG.vega_spec` output. + [float] [[vega-theme]] ====== Default theme to match {kib} diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index b9fc0c9c4ac46..5808e56d6d289 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -93,9 +93,9 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is creating a connector. | `failure` | User is not authorized to create a connector. -.2+| `alert_create` -| `unknown` | User is creating an alert. -| `failure` | User is not authorized to create an alert. +.2+| `rule_create` +| `unknown` | User is creating a rule. +| `failure` | User is not authorized to create a rule. .2+| `space_create` | `unknown` | User is creating a space. @@ -128,38 +128,38 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is updating a connector. | `failure` | User is not authorized to update a connector. -.2+| `alert_update` -| `unknown` | User is updating an alert. -| `failure` | User is not authorized to update an alert. +.2+| `rule_update` +| `unknown` | User is updating a rule. +| `failure` | User is not authorized to update a rule. -.2+| `alert_update_api_key` -| `unknown` | User is updating the API key of an alert. -| `failure` | User is not authorized to update the API key of an alert. +.2+| `rule_update_api_key` +| `unknown` | User is updating the API key of a rule. +| `failure` | User is not authorized to update the API key of a rule. -.2+| `alert_enable` -| `unknown` | User is enabling an alert. -| `failure` | User is not authorized to enable an alert. +.2+| `rule_enable` +| `unknown` | User is enabling a rule. +| `failure` | User is not authorized to enable a rule. -.2+| `alert_disable` -| `unknown` | User is disabling an alert. -| `failure` | User is not authorized to disable an alert. +.2+| `rule_disable` +| `unknown` | User is disabling a rule. +| `failure` | User is not authorized to disable a rule. -.2+| `alert_mute` +.2+| `rule_mute` +| `unknown` | User is muting a rule. +| `failure` | User is not authorized to mute a rule. + +.2+| `rule_unmute` +| `unknown` | User is unmuting a rule. +| `failure` | User is not authorized to unmute a rule. + +.2+| `rule_alert_mute` | `unknown` | User is muting an alert. | `failure` | User is not authorized to mute an alert. -.2+| `alert_unmute` +.2+| `rule_alert_unmute` | `unknown` | User is unmuting an alert. | `failure` | User is not authorized to unmute an alert. -.2+| `alert_instance_mute` -| `unknown` | User is muting an alert instance. -| `failure` | User is not authorized to mute an alert instance. - -.2+| `alert_instance_unmute` -| `unknown` | User is unmuting an alert instance. -| `failure` | User is not authorized to unmute an alert instance. - .2+| `space_update` | `unknown` | User is updating a space. | `failure` | User is not authorized to update a space. @@ -183,9 +183,9 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is deleting a connector. | `failure` | User is not authorized to delete a connector. -.2+| `alert_delete` -| `unknown` | User is deleting an alert. -| `failure` | User is not authorized to delete an alert. +.2+| `rule_delete` +| `unknown` | User is deleting a rule. +| `failure` | User is not authorized to delete a rule. .2+| `space_delete` | `unknown` | User is deleting a space. @@ -218,13 +218,13 @@ Refer to the corresponding {es} logs for potential write errors. | `success` | User has accessed a connector as part of a search operation. | `failure` | User is not authorized to search for connectors. -.2+| `alert_get` -| `success` | User has accessed an alert. -| `failure` | User is not authorized to access an alert. +.2+| `rule_get` +| `success` | User has accessed a rule. +| `failure` | User is not authorized to access a rule. -.2+| `alert_find` -| `success` | User has accessed an alert as part of a search operation. -| `failure` | User is not authorized to search for alerts. +.2+| `rule_find` +| `success` | User has accessed a rule as part of a search operation. +| `failure` | User is not authorized to search for rules. .2+| `space_get` | `success` | User has accessed a space. diff --git a/jest.config.integration.js b/jest.config.integration.js index 50767932a52d7..b6ecb4569b643 100644 --- a/jest.config.integration.js +++ b/jest.config.integration.js @@ -13,7 +13,6 @@ module.exports = { rootDir: '.', roots: ['/src', '/packages'], testMatch: ['**/integration_tests**/*.test.{js,mjs,ts,tsx}'], - testRunner: 'jasmine2', testPathIgnorePatterns: preset.testPathIgnorePatterns.filter( (pattern) => !pattern.includes('integration_tests') ), diff --git a/package.json b/package.json index 310350baf7b2d..36fa086657adf 100644 --- a/package.json +++ b/package.json @@ -97,12 +97,12 @@ "yarn": "^1.21.1" }, "dependencies": { - "@elastic/apm-rum": "^5.6.1", - "@elastic/apm-rum-react": "^1.2.5", + "@elastic/apm-rum": "^5.8.0", + "@elastic/apm-rum-react": "^1.2.11", "@elastic/charts": "30.1.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.13", - "@elastic/ems-client": "7.13.0", + "@elastic/ems-client": "7.14.0", "@elastic/eui": "33.0.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", @@ -133,7 +133,7 @@ "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto", "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n", - "@kbn/interpreter": "link:packages/kbn-interpreter", + "@kbn/interpreter": "link:bazel-bin/packages/kbn-interpreter", "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils", "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging", "@kbn/logging": "link:bazel-bin/packages/kbn-logging", @@ -154,7 +154,7 @@ "@kbn/server-route-repository": "link:bazel-bin/packages/kbn-server-route-repository", "@kbn/std": "link:bazel-bin/packages/kbn-std", "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath", - "@kbn/ui-framework": "link:packages/kbn-ui-framework", + "@kbn/ui-framework": "link:bazel-bin/packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:bazel-bin/packages/kbn-ui-shared-deps", "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types", "@kbn/common-utils": "link:bazel-bin/packages/kbn-common-utils", @@ -224,7 +224,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.14.0", + "elastic-apm-node": "^3.16.0", "elasticsearch": "^16.7.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", @@ -446,8 +446,6 @@ "@bazel/typescript": "^3.5.1", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", - "@elastic/apm-rum": "^5.6.1", - "@elastic/apm-rum-react": "^1.2.5", "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", @@ -468,7 +466,7 @@ "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", - "@kbn/storybook": "link:packages/kbn-storybook", + "@kbn/storybook": "link:bazel-bin/packages/kbn-storybook", "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", "@kbn/test-subj-selector": "link:packages/kbn-test-subj-selector", @@ -841,4 +839,4 @@ "yargs": "^15.4.1", "zlib": "^1.0.5" } -} \ No newline at end of file +} diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 6208910729625..b1c3f580c6baf 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -23,6 +23,7 @@ filegroup( "//packages/kbn-eslint-plugin-eslint:build", "//packages/kbn-expect:build", "//packages/kbn-i18n:build", + "//packages/kbn-interpreter:build", "//packages/kbn-io-ts-utils:build", "//packages/kbn-legacy-logging:build", "//packages/kbn-logging:build", @@ -44,8 +45,10 @@ filegroup( "//packages/kbn-server-http-tools:build", "//packages/kbn-server-route-repository:build", "//packages/kbn-std:build", + "//packages/kbn-storybook:build", "//packages/kbn-telemetry-tools:build", "//packages/kbn-tinymath:build", + "//packages/kbn-ui-framework:build", "//packages/kbn-ui-shared-deps:build", "//packages/kbn-utility-types:build", "//packages/kbn-utils:build", diff --git a/packages/kbn-interpreter/.babelrc b/packages/kbn-interpreter/.babelrc deleted file mode 100644 index 309b3d5b3233d..0000000000000 --- a/packages/kbn-interpreter/.babelrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "presets": ["@kbn/babel-preset/webpack_preset"], - "plugins": [ - "@babel/plugin-transform-modules-commonjs", - ["@babel/plugin-transform-runtime", { - "regenerator": true - }] - ] -} diff --git a/packages/kbn-interpreter/.npmignore b/packages/kbn-interpreter/.npmignore deleted file mode 100644 index b9bc539e63ce4..0000000000000 --- a/packages/kbn-interpreter/.npmignore +++ /dev/null @@ -1,3 +0,0 @@ -src -tasks -.babelrc diff --git a/packages/kbn-interpreter/BUILD.bazel b/packages/kbn-interpreter/BUILD.bazel new file mode 100644 index 0000000000000..4492faabfdf81 --- /dev/null +++ b/packages/kbn-interpreter/BUILD.bazel @@ -0,0 +1,99 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@npm//pegjs:index.bzl", "pegjs") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-interpreter" +PKG_REQUIRE_NAME = "@kbn/interpreter" + +SOURCE_FILES = glob( + [ + "src/**/*", + ] +) + +TYPE_FILES = [] + +SRCS = SOURCE_FILES + TYPE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "common/package.json", + "package.json", +] + +SRC_DEPS = [ + "@npm//lodash", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/lodash", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +pegjs( + name = "grammar", + data = [ + ":grammar/grammar.pegjs" + ], + output_dir = True, + args = [ + "--allowed-start-rules", + "expression,argument", + "-o", + "$(@D)/index.js", + "./%s/grammar/grammar.pegjs" % package_name() + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + allow_js = True, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [":grammar"], + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-interpreter/common/package.json b/packages/kbn-interpreter/common/package.json index 62061138234d9..2f5277a8e8652 100644 --- a/packages/kbn-interpreter/common/package.json +++ b/packages/kbn-interpreter/common/package.json @@ -1,6 +1,5 @@ { "private": true, "main": "../target/common/index.js", - "types": "../target/common/index.d.ts", - "jsnext:main": "../src/common/index.js" + "types": "../target/common/index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-interpreter/src/common/lib/grammar.peg b/packages/kbn-interpreter/grammar/grammar.pegjs similarity index 100% rename from packages/kbn-interpreter/src/common/lib/grammar.peg rename to packages/kbn-interpreter/grammar/grammar.pegjs diff --git a/packages/kbn-interpreter/package.json b/packages/kbn-interpreter/package.json index fc0936f4b5f53..efdb30e105186 100644 --- a/packages/kbn-interpreter/package.json +++ b/packages/kbn-interpreter/package.json @@ -2,11 +2,5 @@ "name": "@kbn/interpreter", "private": "true", "version": "1.0.0", - "license": "SSPL-1.0 OR Elastic License 2.0", - "scripts": { - "interpreter:peg": "../../node_modules/.bin/pegjs src/common/lib/grammar.peg", - "build": "node scripts/build", - "kbn:bootstrap": "node scripts/build --dev", - "kbn:watch": "node scripts/build --dev --watch" - } + "license": "SSPL-1.0 OR Elastic License 2.0" } \ No newline at end of file diff --git a/packages/kbn-interpreter/src/common/index.js b/packages/kbn-interpreter/src/common/index.ts similarity index 76% rename from packages/kbn-interpreter/src/common/index.js rename to packages/kbn-interpreter/src/common/index.ts index b83d8180980cd..524c854b40429 100644 --- a/packages/kbn-interpreter/src/common/index.js +++ b/packages/kbn-interpreter/src/common/index.ts @@ -6,11 +6,19 @@ * Side Public License, v 1. */ -export { fromExpression, toExpression, safeElementFromExpression } from './lib/ast'; +export { + fromExpression, + toExpression, + safeElementFromExpression, + Ast, + ExpressionFunctionAST, +} from './lib/ast'; export { Fn } from './lib/fn'; export { getType } from './lib/get_type'; export { castProvider } from './lib/cast'; -export { parse } from './lib/grammar'; +// @ts-expect-error +// @internal +export { parse } from '../../grammar'; export { getByAlias } from './lib/get_by_alias'; export { Registry } from './lib/registry'; export { addRegistries, register, registryFactory } from './registries'; diff --git a/packages/kbn-interpreter/src/common/lib/ast.d.ts b/packages/kbn-interpreter/src/common/lib/ast.d.ts deleted file mode 100644 index 0e95cb9901df0..0000000000000 --- a/packages/kbn-interpreter/src/common/lib/ast.d.ts +++ /dev/null @@ -1,25 +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 type ExpressionArgAST = string | boolean | number | Ast; - -export interface ExpressionFunctionAST { - type: 'function'; - function: string; - arguments: { - [key: string]: ExpressionArgAST[]; - }; -} - -export interface Ast { - type: 'expression'; - chain: ExpressionFunctionAST[]; -} - -export declare function fromExpression(expression: string): Ast; -export declare function toExpression(astObj: Ast, type?: string): string; diff --git a/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js b/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js index c67a266e1276a..a098a3fdce0f6 100644 --- a/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js +++ b/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { fromExpression } from './ast'; +import { fromExpression } from '@kbn/interpreter/target/common/lib/ast'; import { getType } from './get_type'; describe('ast fromExpression', () => { diff --git a/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js b/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js index c60412f05c15a..b500ca06836a4 100644 --- a/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js +++ b/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { toExpression } from './ast'; +import { toExpression } from '@kbn/interpreter/common'; describe('ast toExpression', () => { describe('single expression', () => { diff --git a/packages/kbn-interpreter/src/common/lib/ast.js b/packages/kbn-interpreter/src/common/lib/ast.ts similarity index 75% rename from packages/kbn-interpreter/src/common/lib/ast.js rename to packages/kbn-interpreter/src/common/lib/ast.ts index fb471e34ccc69..791c94809f35c 100644 --- a/packages/kbn-interpreter/src/common/lib/ast.js +++ b/packages/kbn-interpreter/src/common/lib/ast.ts @@ -7,12 +7,35 @@ */ import { getType } from './get_type'; -import { parse } from './grammar'; +// @ts-expect-error +import { parse } from '../../../grammar'; -function getArgumentString(arg, argKey, level = 0) { +export type ExpressionArgAST = string | boolean | number | Ast; + +export interface ExpressionFunctionAST { + type: 'function'; + function: string; + arguments: { + [key: string]: ExpressionArgAST[]; + }; +} + +export interface Ast { + /** @internal */ + function: any; + /** @internal */ + arguments: any; + type: 'expression'; + chain: ExpressionFunctionAST[]; + /** @internal */ + replace(regExp: RegExp, s: string): string; +} + +function getArgumentString(arg: Ast, argKey: string | undefined, level = 0) { const type = getType(arg); - function maybeArgKey(argKey, argString) { + // eslint-disable-next-line @typescript-eslint/no-shadow + function maybeArgKey(argKey: string | null | undefined, argString: string) { return argKey == null || argKey === '_' ? argString : `${argKey}=${argString}`; } @@ -36,7 +59,7 @@ function getArgumentString(arg, argKey, level = 0) { throw new Error(`Invalid argument type in AST: ${type}`); } -function getExpressionArgs(block, level = 0) { +function getExpressionArgs(block: Ast, level = 0) { const args = block.arguments; const hasValidArgs = typeof args === 'object' && args != null && !Array.isArray(args); @@ -45,7 +68,7 @@ function getExpressionArgs(block, level = 0) { const argKeys = Object.keys(args); const MAX_LINE_LENGTH = 80; // length before wrapping arguments return argKeys.map((argKey) => - args[argKey].reduce((acc, arg) => { + args[argKey].reduce((acc: any, arg: any) => { const argString = getArgumentString(arg, argKey, level); const lineLength = acc.split('\n').pop().length; @@ -63,12 +86,12 @@ function getExpressionArgs(block, level = 0) { ); } -function fnWithArgs(fnName, args) { +function fnWithArgs(fnName: any, args: any[]) { if (!args || args.length === 0) return fnName; return `${fnName} ${args.join(' ')}`; } -function getExpression(chain, level = 0) { +function getExpression(chain: any[], level = 0) { if (!chain) throw new Error('Expressions must contain a chain'); // break new functions onto new lines if we're not in a nested/sub-expression @@ -90,7 +113,7 @@ function getExpression(chain, level = 0) { .join(separator); } -export function fromExpression(expression, type = 'expression') { +export function fromExpression(expression: string, type = 'expression'): Ast { try { return parse(String(expression), { startRule: type }); } catch (e) { @@ -99,7 +122,7 @@ export function fromExpression(expression, type = 'expression') { } // TODO: OMG This is so bad, we need to talk about the right way to handle bad expressions since some are element based and others not -export function safeElementFromExpression(expression) { +export function safeElementFromExpression(expression: string) { try { return fromExpression(expression); } catch (e) { @@ -116,8 +139,11 @@ Thanks for understanding, } // TODO: Respect the user's existing formatting -export function toExpression(astObj, type = 'expression') { - if (type === 'argument') return getArgumentString(astObj); +export function toExpression(astObj: Ast, type = 'expression'): string { + if (type === 'argument') { + // @ts-ignore + return getArgumentString(astObj); + } const validType = ['expression', 'function'].includes(getType(astObj)); if (!validType) throw new Error('Expression must be an expression or argument function'); diff --git a/packages/kbn-interpreter/src/common/lib/get_type.js b/packages/kbn-interpreter/src/common/lib/get_type.ts similarity index 92% rename from packages/kbn-interpreter/src/common/lib/get_type.js rename to packages/kbn-interpreter/src/common/lib/get_type.ts index 7ae6dab029176..b6dff67bf5dc9 100644 --- a/packages/kbn-interpreter/src/common/lib/get_type.js +++ b/packages/kbn-interpreter/src/common/lib/get_type.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export function getType(node) { +export function getType(node: any): string { if (node == null) return 'null'; if (typeof node === 'object') { if (!node.type) throw new Error('Objects must have a type property'); diff --git a/packages/kbn-interpreter/src/common/lib/grammar.js b/packages/kbn-interpreter/src/common/lib/grammar.js deleted file mode 100644 index 3f473b1beea63..0000000000000 --- a/packages/kbn-interpreter/src/common/lib/grammar.js +++ /dev/null @@ -1,1053 +0,0 @@ -/* - * Generated by PEG.js 0.10.0. - * - * http://pegjs.org/ - */ - -"use strict"; - -function peg$subclass(child, parent) { - function ctor() { this.constructor = child; } - ctor.prototype = parent.prototype; - child.prototype = new ctor(); -} - -function peg$SyntaxError(message, expected, found, location) { - this.message = message; - this.expected = expected; - this.found = found; - this.location = location; - this.name = "SyntaxError"; - - if (typeof Error.captureStackTrace === "function") { - Error.captureStackTrace(this, peg$SyntaxError); - } -} - -peg$subclass(peg$SyntaxError, Error); - -peg$SyntaxError.buildMessage = function(expected, found) { - var DESCRIBE_EXPECTATION_FNS = { - literal: function(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, - - "class": function(expectation) { - var escapedParts = "", - i; - - for (i = 0; i < expectation.parts.length; i++) { - escapedParts += expectation.parts[i] instanceof Array - ? classEscape(expectation.parts[i][0]) + "-" + classEscape(expectation.parts[i][1]) - : classEscape(expectation.parts[i]); - } - - return "[" + (expectation.inverted ? "^" : "") + escapedParts + "]"; - }, - - any: function(expectation) { - return "any character"; - }, - - end: function(expectation) { - return "end of input"; - }, - - other: function(expectation) { - return expectation.description; - } - }; - - function hex(ch) { - return ch.charCodeAt(0).toString(16).toUpperCase(); - } - - function literalEscape(s) { - return s - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\0/g, '\\0') - .replace(/\t/g, '\\t') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); - } - - function classEscape(s) { - return s - .replace(/\\/g, '\\\\') - .replace(/\]/g, '\\]') - .replace(/\^/g, '\\^') - .replace(/-/g, '\\-') - .replace(/\0/g, '\\0') - .replace(/\t/g, '\\t') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); - } - - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); - } - - function describeExpected(expected) { - var descriptions = new Array(expected.length), - i, j; - - for (i = 0; i < expected.length; i++) { - descriptions[i] = describeExpectation(expected[i]); - } - - descriptions.sort(); - - if (descriptions.length > 0) { - for (i = 1, j = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } - } - descriptions.length = j; - } - - switch (descriptions.length) { - case 1: - return descriptions[0]; - - case 2: - return descriptions[0] + " or " + descriptions[1]; - - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; - } - } - - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; - } - - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; -}; - -function peg$parse(input, options) { - options = options !== void 0 ? options : {}; - - var peg$FAILED = {}, - - peg$startRuleFunctions = { expression: peg$parseexpression, argument: peg$parseargument }, - peg$startRuleFunction = peg$parseexpression, - - peg$c0 = "|", - peg$c1 = peg$literalExpectation("|", false), - peg$c2 = function(first, fn) { return fn; }, - peg$c3 = function(first, rest) { - return addMeta({ - type: 'expression', - chain: first ? [first].concat(rest) : [] - }, text(), location()); - }, - peg$c4 = peg$otherExpectation("function"), - peg$c5 = function(name, arg_list) { - return addMeta({ - type: 'function', - function: name, - arguments: arg_list - }, text(), location()); - }, - peg$c6 = "=", - peg$c7 = peg$literalExpectation("=", false), - peg$c8 = function(name, value) { - return { name, value }; - }, - peg$c9 = function(value) { - return { name: '_', value }; - }, - peg$c10 = "$", - peg$c11 = peg$literalExpectation("$", false), - peg$c12 = "{", - peg$c13 = peg$literalExpectation("{", false), - peg$c14 = "}", - peg$c15 = peg$literalExpectation("}", false), - peg$c16 = function(expression) { return expression; }, - peg$c17 = function(value) { - return addMeta(value, text(), location()); - }, - peg$c18 = function(arg) { return arg; }, - peg$c19 = function(args) { - return args.reduce((accumulator, { name, value }) => ({ - ...accumulator, - [name]: (accumulator[name] || []).concat(value) - }), {}); - }, - peg$c20 = /^[a-zA-Z0-9_\-]/, - peg$c21 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"], "_", "-"], false, false), - peg$c22 = function(name) { - return name.join(''); - }, - peg$c23 = peg$otherExpectation("literal"), - peg$c24 = "\"", - peg$c25 = peg$literalExpectation("\"", false), - peg$c26 = function(chars) { return chars.join(''); }, - peg$c27 = "'", - peg$c28 = peg$literalExpectation("'", false), - peg$c29 = function(string) { // this also matches nulls, booleans, and numbers - var result = string.join(''); - // Sort of hacky, but PEG doesn't have backtracking so - // a null/boolean/number rule is hard to read, and performs worse - if (result === 'null') return null; - if (result === 'true') return true; - if (result === 'false') return false; - if (isNaN(Number(result))) return result; // 5bears - return Number(result); - }, - peg$c30 = /^[ \t\r\n]/, - peg$c31 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false), - peg$c32 = "\\", - peg$c33 = peg$literalExpectation("\\", false), - peg$c34 = /^["'(){}<>[\]$`|= \t\n\r]/, - peg$c35 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], false, false), - peg$c36 = function(sequence) { return sequence; }, - peg$c37 = /^[^"'(){}<>[\]$`|= \t\n\r]/, - peg$c38 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], true, false), - peg$c39 = /^[^"]/, - peg$c40 = peg$classExpectation(["\""], true, false), - peg$c41 = /^[^']/, - peg$c42 = peg$classExpectation(["'"], true, false), - - peg$currPos = 0, - peg$savedPos = 0, - peg$posDetailsCache = [{ line: 1, column: 1 }], - peg$maxFailPos = 0, - peg$maxFailExpected = [], - peg$silentFails = 0, - - peg$result; - - if ("startRule" in options) { - if (!(options.startRule in peg$startRuleFunctions)) { - throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); - } - - peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; - } - - function text() { - return input.substring(peg$savedPos, peg$currPos); - } - - function location() { - return peg$computeLocation(peg$savedPos, peg$currPos); - } - - function expected(description, location) { - location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) - - throw peg$buildStructuredError( - [peg$otherExpectation(description)], - input.substring(peg$savedPos, peg$currPos), - location - ); - } - - function error(message, location) { - location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) - - throw peg$buildSimpleError(message, location); - } - - function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text: text, ignoreCase: ignoreCase }; - } - - function peg$classExpectation(parts, inverted, ignoreCase) { - return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; - } - - function peg$anyExpectation() { - return { type: "any" }; - } - - function peg$endExpectation() { - return { type: "end" }; - } - - function peg$otherExpectation(description) { - return { type: "other", description: description }; - } - - function peg$computePosDetails(pos) { - var details = peg$posDetailsCache[pos], p; - - if (details) { - return details; - } else { - p = pos - 1; - while (!peg$posDetailsCache[p]) { - p--; - } - - details = peg$posDetailsCache[p]; - details = { - line: details.line, - column: details.column - }; - - while (p < pos) { - if (input.charCodeAt(p) === 10) { - details.line++; - details.column = 1; - } else { - details.column++; - } - - p++; - } - - peg$posDetailsCache[pos] = details; - return details; - } - } - - function peg$computeLocation(startPos, endPos) { - var startPosDetails = peg$computePosDetails(startPos), - endPosDetails = peg$computePosDetails(endPos); - - return { - start: { - offset: startPos, - line: startPosDetails.line, - column: startPosDetails.column - }, - end: { - offset: endPos, - line: endPosDetails.line, - column: endPosDetails.column - } - }; - } - - function peg$fail(expected) { - if (peg$currPos < peg$maxFailPos) { return; } - - if (peg$currPos > peg$maxFailPos) { - peg$maxFailPos = peg$currPos; - peg$maxFailExpected = []; - } - - peg$maxFailExpected.push(expected); - } - - function peg$buildSimpleError(message, location) { - return new peg$SyntaxError(message, null, null, location); - } - - function peg$buildStructuredError(expected, found, location) { - return new peg$SyntaxError( - peg$SyntaxError.buildMessage(expected, found), - expected, - found, - location - ); - } - - function peg$parseexpression() { - var s0, s1, s2, s3, s4, s5, s6, s7; - - s0 = peg$currPos; - s1 = peg$parsespace(); - if (s1 === peg$FAILED) { - s1 = null; - } - if (s1 !== peg$FAILED) { - s2 = peg$parsefunction(); - if (s2 === peg$FAILED) { - s2 = null; - } - if (s2 !== peg$FAILED) { - s3 = []; - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 124) { - s5 = peg$c0; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c1); } - } - if (s5 !== peg$FAILED) { - s6 = peg$parsespace(); - if (s6 === peg$FAILED) { - s6 = null; - } - if (s6 !== peg$FAILED) { - s7 = peg$parsefunction(); - if (s7 !== peg$FAILED) { - peg$savedPos = s4; - s5 = peg$c2(s2, s7); - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - while (s4 !== peg$FAILED) { - s3.push(s4); - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 124) { - s5 = peg$c0; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c1); } - } - if (s5 !== peg$FAILED) { - s6 = peg$parsespace(); - if (s6 === peg$FAILED) { - s6 = null; - } - if (s6 !== peg$FAILED) { - s7 = peg$parsefunction(); - if (s7 !== peg$FAILED) { - peg$savedPos = s4; - s5 = peg$c2(s2, s7); - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c3(s2, s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsefunction() { - var s0, s1, s2; - - peg$silentFails++; - s0 = peg$currPos; - s1 = peg$parseidentifier(); - if (s1 !== peg$FAILED) { - s2 = peg$parsearg_list(); - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c5(s1, s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c4); } - } - - return s0; - } - - function peg$parseargument_assignment() { - var s0, s1, s2, s3, s4, s5; - - s0 = peg$currPos; - s1 = peg$parseidentifier(); - if (s1 !== peg$FAILED) { - s2 = peg$parsespace(); - if (s2 === peg$FAILED) { - s2 = null; - } - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 61) { - s3 = peg$c6; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c7); } - } - if (s3 !== peg$FAILED) { - s4 = peg$parsespace(); - if (s4 === peg$FAILED) { - s4 = null; - } - if (s4 !== peg$FAILED) { - s5 = peg$parseargument(); - if (s5 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c8(s1, s5); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parseargument(); - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c9(s1); - } - s0 = s1; - } - - return s0; - } - - function peg$parseargument() { - var s0, s1, s2, s3, s4; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 36) { - s1 = peg$c10; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c11); } - } - if (s1 === peg$FAILED) { - s1 = null; - } - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 123) { - s2 = peg$c12; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c13); } - } - if (s2 !== peg$FAILED) { - s3 = peg$parseexpression(); - if (s3 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 125) { - s4 = peg$c14; - peg$currPos++; - } else { - s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c15); } - } - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c16(s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parseliteral(); - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c17(s1); - } - s0 = s1; - } - - return s0; - } - - function peg$parsearg_list() { - var s0, s1, s2, s3, s4; - - s0 = peg$currPos; - s1 = []; - s2 = peg$currPos; - s3 = peg$parsespace(); - if (s3 !== peg$FAILED) { - s4 = peg$parseargument_assignment(); - if (s4 !== peg$FAILED) { - peg$savedPos = s2; - s3 = peg$c18(s4); - s2 = s3; - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - while (s2 !== peg$FAILED) { - s1.push(s2); - s2 = peg$currPos; - s3 = peg$parsespace(); - if (s3 !== peg$FAILED) { - s4 = peg$parseargument_assignment(); - if (s4 !== peg$FAILED) { - peg$savedPos = s2; - s3 = peg$c18(s4); - s2 = s3; - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - } - if (s1 !== peg$FAILED) { - s2 = peg$parsespace(); - if (s2 === peg$FAILED) { - s2 = null; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c19(s1); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseidentifier() { - var s0, s1, s2; - - s0 = peg$currPos; - s1 = []; - if (peg$c20.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c21); } - } - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - if (peg$c20.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c21); } - } - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c22(s1); - } - s0 = s1; - - return s0; - } - - function peg$parseliteral() { - var s0, s1; - - peg$silentFails++; - s0 = peg$parsephrase(); - if (s0 === peg$FAILED) { - s0 = peg$parseunquoted_string_or_number(); - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c23); } - } - - return s0; - } - - function peg$parsephrase() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 34) { - s1 = peg$c24; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c25); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parsedq_char(); - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parsedq_char(); - } - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 34) { - s3 = peg$c24; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c25); } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c26(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 39) { - s1 = peg$c27; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parsesq_char(); - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parsesq_char(); - } - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 39) { - s3 = peg$c27; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c26(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - - return s0; - } - - function peg$parseunquoted_string_or_number() { - var s0, s1, s2; - - s0 = peg$currPos; - s1 = []; - s2 = peg$parseunquoted(); - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - s2 = peg$parseunquoted(); - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c29(s1); - } - s0 = s1; - - return s0; - } - - function peg$parsespace() { - var s0, s1; - - s0 = []; - if (peg$c30.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c31); } - } - if (s1 !== peg$FAILED) { - while (s1 !== peg$FAILED) { - s0.push(s1); - if (peg$c30.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c31); } - } - } - } else { - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseunquoted() { - var s0, s1, s2; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c32; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - if (s1 !== peg$FAILED) { - if (peg$c34.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c35); } - } - if (s2 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c32; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c36(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - if (peg$c37.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c38); } - } - } - - return s0; - } - - function peg$parsedq_char() { - var s0, s1, s2; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c32; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c24; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c25); } - } - if (s2 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c32; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c36(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - if (peg$c39.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c40); } - } - } - - return s0; - } - - function peg$parsesq_char() { - var s0, s1, s2; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c32; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 39) { - s2 = peg$c27; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s2 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c32; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c36(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - if (peg$c41.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c42); } - } - } - - return s0; - } - - - function addMeta(node, text, { start: { offset: start }, end: { offset: end } }) { - if (!options.addMeta) return node; - return { node, text, start, end }; - } - - - peg$result = peg$startRuleFunction(); - - if (peg$result !== peg$FAILED && peg$currPos === input.length) { - return peg$result; - } else { - if (peg$result !== peg$FAILED && peg$currPos < input.length) { - peg$fail(peg$endExpectation()); - } - - throw peg$buildStructuredError( - peg$maxFailExpected, - peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, - peg$maxFailPos < input.length - ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) - : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) - ); - } -} - -module.exports = { - SyntaxError: peg$SyntaxError, - parse: peg$parse -}; diff --git a/packages/kbn-interpreter/src/common/lib/registry.d.ts b/packages/kbn-interpreter/src/common/lib/registry.d.ts deleted file mode 100644 index 766839ebf0e02..0000000000000 --- a/packages/kbn-interpreter/src/common/lib/registry.d.ts +++ /dev/null @@ -1,25 +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 class Registry { - constructor(prop?: string); - - public wrapper(obj: ItemSpec): Item; - - public register(fn: () => ItemSpec): void; - - public toJS(): { [key: string]: any }; - - public toArray(): Item[]; - - public get(name: string): Item; - - public getProp(): string; - - public reset(): void; -} diff --git a/packages/kbn-interpreter/src/common/lib/registry.js b/packages/kbn-interpreter/src/common/lib/registry.ts similarity index 73% rename from packages/kbn-interpreter/src/common/lib/registry.js rename to packages/kbn-interpreter/src/common/lib/registry.ts index 309f92ea24f6d..11f41ff736e96 100644 --- a/packages/kbn-interpreter/src/common/lib/registry.js +++ b/packages/kbn-interpreter/src/common/lib/registry.ts @@ -8,49 +8,59 @@ import { clone } from 'lodash'; -export class Registry { +export class Registry { + private readonly _prop: string; + // eslint-disable-next-line @typescript-eslint/ban-types + private _indexed: Object; + constructor(prop = 'name') { if (typeof prop !== 'string') throw new Error('Registry property name must be a string'); this._prop = prop; this._indexed = new Object(); } - wrapper(obj) { + wrapper(obj: ItemSpec): Item { + // @ts-ignore return obj; } - register(fn) { + register(fn: () => ItemSpec): void { const obj = typeof fn === 'function' ? fn() : fn; + // @ts-ignore if (typeof obj !== 'object' || !obj[this._prop]) { throw new Error(`Registered functions must return an object with a ${this._prop} property`); } + // @ts-ignore this._indexed[obj[this._prop].toLowerCase()] = this.wrapper(obj); } - toJS() { + toJS(): { [key: string]: any } { return Object.keys(this._indexed).reduce((acc, key) => { + // @ts-ignore acc[key] = this.get(key); return acc; }, {}); } - toArray() { + toArray(): Item[] { return Object.keys(this._indexed).map((key) => this.get(key)); } - get(name) { + get(name: string): Item { + // @ts-ignore if (name === undefined) return null; const lowerCaseName = name.toLowerCase(); + // @ts-ignore return this._indexed[lowerCaseName] ? clone(this._indexed[lowerCaseName]) : null; } - getProp() { + getProp(): string { return this._prop; } - reset() { + reset(): void { this._indexed = new Object(); } } diff --git a/packages/kbn-interpreter/tasks/build/__fixtures__/sample.js b/packages/kbn-interpreter/tasks/build/__fixtures__/sample.js deleted file mode 100644 index f831545743f10..0000000000000 --- a/packages/kbn-interpreter/tasks/build/__fixtures__/sample.js +++ /dev/null @@ -1,3 +0,0 @@ -/* eslint-disable */ -import util from 'util'; -console.log(util.format('hello world')); diff --git a/packages/kbn-interpreter/tasks/build/cli.js b/packages/kbn-interpreter/tasks/build/cli.js deleted file mode 100644 index 82e4475b409c3..0000000000000 --- a/packages/kbn-interpreter/tasks/build/cli.js +++ /dev/null @@ -1,82 +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. - */ - -const { relative } = require('path'); - -const getopts = require('getopts'); -const del = require('del'); -const supportsColor = require('supports-color'); -const { ToolingLog, withProcRunner, pickLevelFromFlags } = require('@kbn/dev-utils'); - -const { ROOT_DIR, BUILD_DIR } = require('./paths'); - -const unknownFlags = []; -const flags = getopts(process.argv, { - boolean: ['watch', 'dev', 'help', 'debug'], - unknown(name) { - unknownFlags.push(name); - }, -}); - -const log = new ToolingLog({ - level: pickLevelFromFlags(flags), - writeTo: process.stdout, -}); - -if (unknownFlags.length) { - log.error(`Unknown flag(s): ${unknownFlags.join(', ')}`); - flags.help = true; - process.exitCode = 1; -} - -if (flags.help) { - log.info(` - Simple build tool for @kbn/interpreter package - - --dev Build for development, include source maps - --watch Run in watch mode - --debug Turn on debug logging - `); - process.exit(); -} - -withProcRunner(log, async (proc) => { - log.info('Deleting old output'); - await del(BUILD_DIR); - - const cwd = ROOT_DIR; - const env = { ...process.env }; - if (supportsColor.stdout) { - env.FORCE_COLOR = 'true'; - } - - log.info(`Starting babel ${flags.watch ? ' in watch mode' : ''}`); - await Promise.all([ - proc.run('babel ', { - cmd: 'babel', - args: [ - 'src', - '--ignore', - `*.test.js`, - '--out-dir', - relative(cwd, BUILD_DIR), - '--copy-files', - ...(flags.dev ? ['--source-maps', 'inline'] : []), - ...(flags.watch ? ['--watch'] : ['--quiet']), - ], - wait: true, - env, - cwd, - }), - ]); - - log.success('Complete'); -}).catch((error) => { - log.error(error); - process.exit(1); -}); diff --git a/packages/kbn-interpreter/tasks/build/paths.js b/packages/kbn-interpreter/tasks/build/paths.js deleted file mode 100644 index a4cdba90a110a..0000000000000 --- a/packages/kbn-interpreter/tasks/build/paths.js +++ /dev/null @@ -1,15 +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. - */ - -const { resolve } = require('path'); - -exports.ROOT_DIR = resolve(__dirname, '../../'); -exports.SOURCE_DIR = resolve(exports.ROOT_DIR, 'src'); -exports.BUILD_DIR = resolve(exports.ROOT_DIR, 'target'); - -exports.BABEL_PRESET_PATH = require.resolve('@kbn/babel-preset/webpack_preset'); diff --git a/packages/kbn-interpreter/tsconfig.json b/packages/kbn-interpreter/tsconfig.json index 3b81bbb118a55..011ed877146e8 100644 --- a/packages/kbn-interpreter/tsconfig.json +++ b/packages/kbn-interpreter/tsconfig.json @@ -1,7 +1,21 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "tsBuildInfoFile": "../../build/tsbuildinfo/packages/kbn-interpreter" + "allowJs": true, + "incremental": true, + "outDir": "./target", + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-interpreter/src", + "stripInternal": true, + "types": [ + "jest", + "node" + ] }, - "include": ["index.d.ts", "src/**/*.d.ts"] + "include": [ + "src/**/*", + ] } diff --git a/packages/kbn-monaco/src/monaco_imports.ts b/packages/kbn-monaco/src/monaco_imports.ts index 92ea23347c374..3f689e6ec0c01 100644 --- a/packages/kbn-monaco/src/monaco_imports.ts +++ b/packages/kbn-monaco/src/monaco_imports.ts @@ -7,7 +7,6 @@ */ /* eslint-disable @kbn/eslint/module_migration */ - import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import 'monaco-editor/esm/vs/base/common/worker/simpleWorker'; @@ -23,4 +22,7 @@ import 'monaco-editor/esm/vs/editor/contrib/hover/hover.js'; // Needed for hover import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints.js'; // Needed for signature import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/bracketMatching.js'; // Needed for brackets matching highlight +import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js'; // Needed for basic javascript support +import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution.js'; // Needed for basic xml support + export { monaco }; diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index c9ec09760986c..80f7332d44048 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -34,7 +34,7 @@ pageLoadAssetSize: indexManagement: 140608 indexPatternManagement: 28222 infra: 184320 - fleet: 450005 + fleet: 465774 ingestPipelines: 58003 inputControlVis: 172675 inspector: 148711 diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 1311eb4d7c638..e455f487d1384 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -48479,7 +48479,7 @@ async function runBazelCommandWithRunner(bazelCommandRunner, bazelArgs, offline stdio: 'pipe' }); - if (offline || !offline) { + if (offline) { bazelArgs = [...bazelArgs, '--config=offline']; } diff --git a/packages/kbn-pm/src/utils/bazel/run.ts b/packages/kbn-pm/src/utils/bazel/run.ts index 5f3743876e0e4..c030081e53daa 100644 --- a/packages/kbn-pm/src/utils/bazel/run.ts +++ b/packages/kbn-pm/src/utils/bazel/run.ts @@ -29,7 +29,7 @@ async function runBazelCommandWithRunner( stdio: 'pipe', }; - if (offline || !offline) { + if (offline) { bazelArgs = [...bazelArgs, '--config=offline']; } diff --git a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts index a483da152ac89..d208624b69fc5 100644 --- a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts @@ -95,6 +95,10 @@ export const filterExceptionItems = ( } }, []); + if (entries.length === 0) { + return acc; + } + const item = { ...exception, entries }; if (exceptionListItemSchema.is(item)) { diff --git a/packages/kbn-storybook/BUILD.bazel b/packages/kbn-storybook/BUILD.bazel new file mode 100644 index 0000000000000..e18256aeb8da4 --- /dev/null +++ b/packages/kbn-storybook/BUILD.bazel @@ -0,0 +1,98 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-storybook" +PKG_REQUIRE_NAME = "@kbn/storybook" + +SOURCE_FILES = glob( + [ + "lib/**/*.ts", + "lib/**/*.tsx", + "*.ts", + ], + exclude = ["**/*.test.*"], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "preset/package.json", + "templates/index.ejs", + "package.json", + "README.md", + "preset.js", +] + +SRC_DEPS = [ + "//packages/kbn-dev-utils", + "//packages/kbn-ui-shared-deps", + "@npm//@storybook/addons", + "@npm//@storybook/api", + "@npm//@storybook/components", + "@npm//@storybook/core", + "@npm//@storybook/node-logger", + "@npm//@storybook/react", + "@npm//@storybook/theming", + "@npm//loader-utils", + "@npm//react", + "@npm//webpack", + "@npm//webpack-merge", +] + +TYPES_DEPS = [ + "@npm//@types/loader-utils", + "@npm//@types/node", + "@npm//@types/webpack", + "@npm//@types/webpack-merge", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-storybook/package.json b/packages/kbn-storybook/package.json index f2e4c9b3418b1..f3c12f19a0793 100644 --- a/packages/kbn-storybook/package.json +++ b/packages/kbn-storybook/package.json @@ -7,10 +7,5 @@ "types": "./target/index.d.ts", "kibana": { "devOnly": true - }, - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" } } \ No newline at end of file diff --git a/packages/kbn-storybook/preset.js b/packages/kbn-storybook/preset.js index c1b7195c141b4..be0012a3818b1 100644 --- a/packages/kbn-storybook/preset.js +++ b/packages/kbn-storybook/preset.js @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +// eslint-disable-next-line const webpackConfig = require('./target/webpack.config').default; module.exports = { diff --git a/packages/kbn-storybook/preset/package.json b/packages/kbn-storybook/preset/package.json new file mode 100644 index 0000000000000..7cd7517d64dde --- /dev/null +++ b/packages/kbn-storybook/preset/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "main": "../preset.js" +} \ No newline at end of file diff --git a/packages/kbn-storybook/lib/templates/index.ejs b/packages/kbn-storybook/templates/index.ejs similarity index 100% rename from packages/kbn-storybook/lib/templates/index.ejs rename to packages/kbn-storybook/templates/index.ejs diff --git a/packages/kbn-storybook/tsconfig.json b/packages/kbn-storybook/tsconfig.json index 586f5ea32c056..1f6886c45c505 100644 --- a/packages/kbn-storybook/tsconfig.json +++ b/packages/kbn-storybook/tsconfig.json @@ -1,14 +1,15 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "target", "skipLibCheck": true, "declaration": true, "declarationMap": true, "sourceMap": true, "sourceRoot": "../../../../packages/kbn-storybook", + "target": "es2015", "types": ["node"] }, - "include": ["*.ts", "lib/**/*.ts", "lib/**/*.tsx", "../../typings/index.d.ts"] + "include": ["*.ts", "lib/**/*.ts", "lib/**/*.tsx"] } diff --git a/packages/kbn-storybook/typings.ts b/packages/kbn-storybook/typings.ts new file mode 100644 index 0000000000000..6c5d8f4da5709 --- /dev/null +++ b/packages/kbn-storybook/typings.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +// Storybook react doesn't declare this in its typings, but it's there. +declare module '@storybook/react/standalone'; + +// Storybook references this module. It's @ts-ignored in the codebase but when +// built into its dist it strips that out. Add it here to avoid a type checking +// error. +// +// See https://github.com/storybookjs/storybook/issues/11684 +declare module 'react-syntax-highlighter/dist/cjs/create-element'; +declare module 'react-syntax-highlighter/dist/cjs/prism-light'; diff --git a/packages/kbn-storybook/webpack.config.ts b/packages/kbn-storybook/webpack.config.ts index 972caf8d481fe..41d3ee1f7ee5c 100644 --- a/packages/kbn-storybook/webpack.config.ts +++ b/packages/kbn-storybook/webpack.config.ts @@ -94,7 +94,7 @@ export default function ({ config: storybookConfig }: { config: Configuration }) return plugin.options && typeof plugin.options.template === 'string'; }); if (htmlWebpackPlugin) { - htmlWebpackPlugin.options.template = require.resolve('../lib/templates/index.ejs'); + htmlWebpackPlugin.options.template = require.resolve('../templates/index.ejs'); } return webpackMerge(storybookConfig, config); diff --git a/packages/kbn-test/src/functional_tests/lib/auth.ts b/packages/kbn-test/src/functional_tests/lib/auth.ts deleted file mode 100644 index abd1e0f9e7d5e..0000000000000 --- a/packages/kbn-test/src/functional_tests/lib/auth.ts +++ /dev/null @@ -1,188 +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 fs from 'fs'; -import util from 'util'; -import { format as formatUrl } from 'url'; -import request from 'request'; -import type { ToolingLog } from '@kbn/dev-utils'; - -export const DEFAULT_SUPERUSER_PASS = 'changeme'; -const readFile = util.promisify(fs.readFile); - -function delay(delayMs: number) { - return new Promise((res) => setTimeout(res, delayMs)); -} - -interface UpdateCredentialsOptions { - port: number; - auth: string; - username: string; - password: string; - retries?: number; - protocol: string; - caCert?: Buffer | string; -} -async function updateCredentials({ - port, - auth, - username, - password, - retries = 10, - protocol, - caCert, -}: UpdateCredentialsOptions): Promise { - const result = await new Promise<{ body: any; httpResponse: request.Response }>( - (resolve, reject) => - request( - { - method: 'PUT', - uri: formatUrl({ - protocol: `${protocol}:`, - auth, - hostname: 'localhost', - port, - pathname: `/_security/user/${username}/_password`, - }), - json: true, - body: { password }, - ca: caCert, - }, - (err, httpResponse, body) => { - if (err) return reject(err); - resolve({ httpResponse, body }); - } - ) - ); - - const { body, httpResponse } = result; - const { statusCode } = httpResponse; - - if (statusCode === 200) { - return; - } - - if (retries > 0) { - await delay(2500); - return await updateCredentials({ - port, - auth, - username, - password, - retries: retries - 1, - protocol, - caCert, - }); - } - - throw new Error(`${statusCode} response, expected 200 -- ${JSON.stringify(body)}`); -} - -interface SetupUsersOptions { - log: ToolingLog; - esPort: number; - updates: Array<{ username: string; password: string; roles?: string[] }>; - protocol?: string; - caPath?: string; -} - -export async function setupUsers({ - log, - esPort, - updates, - protocol = 'http', - caPath, -}: SetupUsersOptions): Promise { - // track the current credentials for the `elastic` user as - // they will likely change as we apply updates - let auth = `elastic:${DEFAULT_SUPERUSER_PASS}`; - const caCert = caPath ? await readFile(caPath) : undefined; - - for (const { username, password, roles } of updates) { - // If working with a built-in user, just change the password - if (['logstash_system', 'elastic', 'kibana'].includes(username)) { - await updateCredentials({ port: esPort, auth, username, password, protocol, caCert }); - log.info('setting %j user password to %j', username, password); - - // If not a builtin user, add them - } else { - await insertUser({ port: esPort, auth, username, password, roles, protocol, caCert }); - log.info('Added %j user with password to %j', username, password); - } - - if (username === 'elastic') { - auth = `elastic:${password}`; - } - } -} - -interface InserUserOptions { - port: number; - auth: string; - username: string; - password: string; - roles?: string[]; - retries?: number; - protocol: string; - caCert?: Buffer | string; -} -async function insertUser({ - port, - auth, - username, - password, - roles = [], - retries = 10, - protocol, - caCert, -}: InserUserOptions): Promise { - const result = await new Promise<{ body: any; httpResponse: request.Response }>( - (resolve, reject) => - request( - { - method: 'POST', - uri: formatUrl({ - protocol: `${protocol}:`, - auth, - hostname: 'localhost', - port, - pathname: `/_security/user/${username}`, - }), - json: true, - body: { password, roles }, - ca: caCert, - }, - (err, httpResponse, body) => { - if (err) return reject(err); - resolve({ httpResponse, body }); - } - ) - ); - - const { body, httpResponse } = result; - const { statusCode } = httpResponse; - if (statusCode === 200) { - return; - } - - if (retries > 0) { - await delay(2500); - return await insertUser({ - port, - auth, - username, - password, - roles, - retries: retries - 1, - protocol, - caCert, - }); - } - - throw new Error(`${statusCode} response, expected 200 -- ${JSON.stringify(body)}`); -} diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts index 7ba9a3c1c4733..da83d8285a6b5 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts @@ -12,8 +12,6 @@ import { KIBANA_ROOT } from './paths'; import type { Config } from '../../functional_test_runner/'; import { createTestEsCluster } from '../../es'; -import { setupUsers, DEFAULT_SUPERUSER_PASS } from './auth'; - interface RunElasticsearchOptions { log: ToolingLog; esFrom: string; @@ -34,9 +32,7 @@ export async function runElasticsearch({ const cluster = createTestEsCluster({ port: config.get('servers.elasticsearch.port'), - password: isSecurityEnabled - ? DEFAULT_SUPERUSER_PASS - : config.get('servers.elasticsearch.password'), + password: isSecurityEnabled ? 'changeme' : config.get('servers.elasticsearch.password'), license, log, basePath: resolve(KIBANA_ROOT, '.es'), @@ -49,22 +45,5 @@ export async function runElasticsearch({ await cluster.start(); - if (isSecurityEnabled) { - await setupUsers({ - log, - esPort: config.get('servers.elasticsearch.port'), - updates: [config.get('servers.elasticsearch'), config.get('servers.kibana')], - protocol: config.get('servers.elasticsearch').protocol, - caPath: getRelativeCertificateAuthorityPath(config.get('kbnTestServer.serverArgs')), - }); - } - return cluster; } - -function getRelativeCertificateAuthorityPath(esConfig: string[] = []) { - const caConfig = esConfig.find( - (config) => config.indexOf('--elasticsearch.ssl.certificateAuthorities') === 0 - ); - return caConfig ? caConfig.split('=')[1] : undefined; -} diff --git a/packages/kbn-test/src/index.ts b/packages/kbn-test/src/index.ts index dd5343b0118b3..af100a33ea3a7 100644 --- a/packages/kbn-test/src/index.ts +++ b/packages/kbn-test/src/index.ts @@ -29,8 +29,6 @@ export { esTestConfig, createTestEsCluster } from './es'; export { kbnTestConfig, kibanaServerTestUser, kibanaTestUser, adminTestUser } from './kbn'; -export { setupUsers, DEFAULT_SUPERUSER_PASS } from './functional_tests/lib/auth'; - export { readConfigFile } from './functional_test_runner/lib/config/read_config_file'; export { runFtrCli } from './functional_test_runner/cli'; diff --git a/packages/kbn-ui-framework/BUILD.bazel b/packages/kbn-ui-framework/BUILD.bazel new file mode 100644 index 0000000000000..f8cf5035bdc5f --- /dev/null +++ b/packages/kbn-ui-framework/BUILD.bazel @@ -0,0 +1,47 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-ui-framework" +PKG_REQUIRE_NAME = "@kbn/ui-framework" + +SOURCE_FILES = glob([ + "dist/**/*", +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [] + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [ + ":srcs", + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index 438b1e0b2e77b..9d18c8033ff67 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -7,7 +7,6 @@ */ const Path = require('path'); -const Os = require('os'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); @@ -31,7 +30,8 @@ module.exports = { 'kbn-ui-shared-deps.v8.light': ['@elastic/eui/dist/eui_theme_amsterdam_light.css'], }, context: __dirname, - devtool: 'cheap-source-map', + // cheap-source-map should be used if needed + devtool: false, output: { path: UiSharedDeps.distDir, filename: '[name].js', @@ -39,7 +39,6 @@ module.exports = { devtoolModuleFilenameTemplate: (info) => `kbn-ui-shared-deps/${Path.relative(REPO_ROOT, info.absoluteResourcePath)}`, library: '__kbnSharedDeps__', - futureEmitAssets: true, }, module: { @@ -111,7 +110,7 @@ module.exports = { optimization: { minimizer: [ new CssMinimizerPlugin({ - parallel: Math.min(Os.cpus().length, 2), + parallel: false, minimizerOptions: { preset: [ 'default', @@ -125,7 +124,7 @@ module.exports = { cache: false, sourceMap: false, extractComments: false, - parallel: Math.min(Os.cpus().length, 2), + parallel: false, terserOptions: { compress: true, mangle: true, diff --git a/src/core/public/apm_system.ts b/src/core/public/apm_system.ts index 32fc330375991..f5af7011e632e 100644 --- a/src/core/public/apm_system.ts +++ b/src/core/public/apm_system.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import type { ApmBase } from '@elastic/apm-rum'; +import type { ApmBase, AgentConfigOptions } from '@elastic/apm-rum'; import { modifyUrl } from '@kbn/std'; import type { InternalApplicationStart } from './application'; @@ -18,9 +18,8 @@ const HTTP_REQUEST_TRANSACTION_NAME_REGEX = /^(GET|POST|PUT|HEAD|PATCH|DELETE|OP * that lives in the Kibana Platform. */ -interface ApmConfig { - // AgentConfigOptions is not exported from @elastic/apm-rum - active?: boolean; +interface ApmConfig extends AgentConfigOptions { + // Kibana-specific config settings: globalLabels?: Record; } diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 3ed164088bf5c..de9e4d4496f3b 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -15,13 +15,13 @@ import { import { createElement } from 'react'; import { BehaviorSubject, Subject } from 'rxjs'; import { bufferCount, take, takeUntil } from 'rxjs/operators'; -import { shallow, mount } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { httpServiceMock } from '../http/http_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; -import { App, PublicAppInfo, AppNavLinkStatus, AppStatus, AppUpdater } from './types'; +import { App, AppDeepLink, AppNavLinkStatus, AppStatus, AppUpdater, PublicAppInfo } from './types'; import { act } from 'react-dom/test-utils'; const createApp = (props: Partial): App => { @@ -365,6 +365,85 @@ describe('#setup()', () => { expect(MockHistory.push).toHaveBeenCalledWith('/app/app1', undefined); MockHistory.push.mockClear(); }); + + it('preserves the deep links if the update does not modify them', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + const updater$ = new BehaviorSubject((app) => ({})); + + const deepLinks: AppDeepLink[] = [ + { + id: 'foo', + title: 'Foo', + searchable: true, + navLinkStatus: AppNavLinkStatus.visible, + path: '/foo', + }, + { + id: 'bar', + title: 'Bar', + searchable: false, + navLinkStatus: AppNavLinkStatus.hidden, + path: '/bar', + }, + ]; + + setup.register(pluginId, createApp({ id: 'app1', deepLinks, updater$ })); + + const { applications$ } = await service.start(startDeps); + + updater$.next((app) => ({ defaultPath: '/foo' })); + + let appInfos = await applications$.pipe(take(1)).toPromise(); + + expect(appInfos.get('app1')!.deepLinks).toEqual([ + { + deepLinks: [], + id: 'foo', + keywords: [], + navLinkStatus: 1, + path: '/foo', + searchable: true, + title: 'Foo', + }, + { + deepLinks: [], + id: 'bar', + keywords: [], + navLinkStatus: 3, + path: '/bar', + searchable: false, + title: 'Bar', + }, + ]); + + updater$.next((app) => ({ + deepLinks: [ + { + id: 'bar', + title: 'Bar', + searchable: false, + navLinkStatus: AppNavLinkStatus.hidden, + path: '/bar', + }, + ], + })); + + appInfos = await applications$.pipe(take(1)).toPromise(); + + expect(appInfos.get('app1')!.deepLinks).toEqual([ + { + deepLinks: [], + id: 'bar', + keywords: [], + navLinkStatus: 3, + path: '/bar', + searchable: false, + title: 'Bar', + }, + ]); + }); }); }); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 8c6090caabce1..2e804bf2f5413 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -54,6 +54,7 @@ function filterAvailable(m: Map, capabilities: Capabilities) { ) ); } + const findMounter = (mounters: Map, appRoute?: string) => [...mounters].find(([, mounter]) => mounter.appRoute === appRoute); @@ -414,13 +415,11 @@ const updateStatus = (app: App, statusUpdaters: AppUpdaterWrapper[]): App => { changes.navLinkStatus ?? AppNavLinkStatus.default, fields.navLinkStatus ?? AppNavLinkStatus.default ), - // deepLinks take the last defined update - deepLinks: fields.deepLinks - ? populateDeepLinkDefaults(fields.deepLinks) - : changes.deepLinks, + ...(fields.deepLinks ? { deepLinks: populateDeepLinkDefaults(fields.deepLinks) } : {}), }; } }); + return { ...app, ...changes, diff --git a/src/core/public/application/utils/get_app_info.test.ts b/src/core/public/application/utils/get_app_info.test.ts index fa1e2dd9a4537..25614d1d1dca9 100644 --- a/src/core/public/application/utils/get_app_info.test.ts +++ b/src/core/public/application/utils/get_app_info.test.ts @@ -185,15 +185,18 @@ describe('getAppInfo', () => { it('adds default deepLinks when needed', () => { const app = createApp({ + order: 3, deepLinks: [ { id: 'sub-id', title: 'sub-title', + order: 2, deepLinks: [ { id: 'sub-sub-id', title: 'sub-sub-title', path: '/sub-sub', + order: 1, keywords: ['sub sub'], }, ], @@ -210,12 +213,14 @@ describe('getAppInfo', () => { searchable: true, appRoute: `/app/some-id`, keywords: [], + order: 3, deepLinks: [ { id: 'sub-id', title: 'sub-title', navLinkStatus: AppNavLinkStatus.hidden, searchable: true, + order: 2, keywords: [], deepLinks: [ { @@ -223,6 +228,7 @@ describe('getAppInfo', () => { title: 'sub-sub-title', navLinkStatus: AppNavLinkStatus.hidden, searchable: true, + order: 1, path: '/sub-sub', keywords: ['sub sub'], deepLinks: [], diff --git a/src/core/public/application/utils/get_app_info.ts b/src/core/public/application/utils/get_app_info.ts index 6c753b7a71a0f..b5a3f0b0a0f13 100644 --- a/src/core/public/application/utils/get_app_info.ts +++ b/src/core/public/application/utils/get_app_info.ts @@ -41,9 +41,7 @@ function getDeepLinkInfos(deepLinks?: AppDeepLink[]): PublicAppDeepLinkInfo[] { return deepLinks.map( ({ navLinkStatus = AppNavLinkStatus.default, ...rawDeepLink }): PublicAppDeepLinkInfo => { return { - id: rawDeepLink.id, - title: rawDeepLink.title, - path: rawDeepLink.path, + ...rawDeepLink, keywords: rawDeepLink.keywords ?? [], navLinkStatus: navLinkStatus === AppNavLinkStatus.default ? AppNavLinkStatus.hidden : navLinkStatus, diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 06277d9351922..95091a761639b 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -22,6 +22,7 @@ export class DocLinksService { const ELASTIC_WEBSITE_URL = 'https://www.elastic.co/'; const ELASTICSEARCH_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`; const KIBANA_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/`; + const FLEET_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/fleet/${DOC_LINK_VERSION}/`; const PLUGIN_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`; return deepFreeze({ @@ -400,6 +401,19 @@ export class DocLinksService { urlDecode: `${ELASTICSEARCH_DOCS}urldecode-processor.html`, userAgent: `${ELASTICSEARCH_DOCS}user-agent-processor.html`, }, + fleet: { + guide: `${FLEET_DOCS}index.html`, + fleetServer: `${FLEET_DOCS}fleet-server.html`, + fleetServerAddFleetServer: `${FLEET_DOCS}fleet-server.html#add-fleet-server`, + settings: `${FLEET_DOCS}fleet-settings.html#fleet-server-hosts-setting`, + settingsFleetServerHostSettings: `${FLEET_DOCS}fleet-settings.html#fleet-server-hosts-setting`, + troubleshooting: `${FLEET_DOCS}fleet-troubleshooting.html`, + elasticAgent: `${FLEET_DOCS}elastic-agent-installation-configuration.html`, + datastreams: `${FLEET_DOCS}data-streams.html`, + datastreamsNamingScheme: `${FLEET_DOCS}data-streams.html#data-streams-naming-scheme`, + upgradeElasticAgent: `${FLEET_DOCS}upgrade-elastic-agent.html`, + upgradeElasticAgent712lower: `${FLEET_DOCS}upgrade-elastic-agent.html#upgrade-7.12-lower`, + }, }, }); } @@ -587,5 +601,18 @@ export interface DocLinksStart { readonly plugins: Record; readonly snapshotRestore: Record; readonly ingest: Record; + 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; + }>; }; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index d3426b50f7614..6cc2b3f321fb7 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -664,6 +664,19 @@ export interface DocLinksStart { readonly plugins: Record; readonly snapshotRestore: Record; readonly ingest: Record; + 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; + }>; }; } diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index dc24f889cd8dd..afe1b45175f86 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -126,14 +126,12 @@ export class CoreUsageDataService implements CoreService any) => fn.bind(null, {}); let certificate: string; let key: string; -beforeAll(() => { - certificate = readFileSync(KBN_CERT_PATH, 'utf8'); - key = readFileSync(KBN_KEY_PATH, 'utf8'); +beforeAll(async () => { + certificate = await readFile(KBN_CERT_PATH, 'utf8'); + key = await readFile(KBN_KEY_PATH, 'utf8'); }); beforeEach(() => { @@ -1409,6 +1410,19 @@ describe('setup contract', () => { }); describe('#registerStaticDir', () => { + const assetFolder = join(__dirname, 'integration_tests', 'fixtures', 'static'); + let tempDir: string; + + beforeAll(async () => { + tempDir = await mkdtemp('cache-test'); + }); + + afterAll(async () => { + if (tempDir) { + await rmdir(tempDir, { recursive: true }); + } + }); + test('does not throw if called after stop', async () => { const { registerStaticDir } = await server.setup(config); await server.stop(); @@ -1416,6 +1430,111 @@ describe('setup contract', () => { registerStaticDir('/path1/{path*}', '/path/to/resource'); }).not.toThrow(); }); + + test('returns correct headers for static assets', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + }); + + test('returns compressed version if present', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/compression_available.json') + .set('accept-encoding', 'gzip') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + expect(response.get('content-encoding')).toEqual('gzip'); + }); + + test('returns uncompressed version if compressed asset is not available', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('accept-encoding', 'gzip') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + expect(response.get('content-encoding')).toBeUndefined(); + }); + + test('returns a 304 if etag value matches', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .expect(200); + + const etag = response.get('etag'); + expect(etag).not.toBeUndefined(); + + await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('If-None-Match', etag) + .expect(304); + }); + + test('serves content if etag values does not match', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + + await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('If-None-Match', `"definitely not a valid etag"`) + .expect(200); + }); + + test('dynamically updates depending on the content of the file', async () => { + const tempFile = join(tempDir, 'some_file.json'); + + const { registerStaticDir, server: innerServer } = await server.setup(config); + registerStaticDir('/static/{path*}', tempDir); + + await server.start(); + + await supertest(innerServer.listener).get('/static/some_file.json').expect(404); + + await writeFile(tempFile, `{ "over": 9000 }`); + + let response = await supertest(innerServer.listener) + .get('/static/some_file.json') + .expect(200); + + const etag1 = response.get('etag'); + + await writeFile(tempFile, `{ "over": 42 }`); + + response = await supertest(innerServer.listener).get('/static/some_file.json').expect(200); + + const etag2 = response.get('etag'); + + expect(etag1).not.toEqual(etag2); + }); }); describe('#registerOnPreRouting', () => { diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 8b4c3b9416152..d43d86d587d06 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -465,7 +465,13 @@ export class HttpServer { lookupCompressed: true, }, }, - options: { auth: false }, + options: { + auth: false, + cache: { + privacy: 'public', + otherwise: 'must-revalidate', + }, + }, }); } diff --git a/src/core/server/http/integration_tests/fixtures/static/compression_available.json b/src/core/server/http/integration_tests/fixtures/static/compression_available.json new file mode 100644 index 0000000000000..1f878fb465cff --- /dev/null +++ b/src/core/server/http/integration_tests/fixtures/static/compression_available.json @@ -0,0 +1,3 @@ +{ + "hello": "dolly" +} diff --git a/src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz b/src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz new file mode 100644 index 0000000000000..e77819d2e8e59 Binary files /dev/null and b/src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz differ diff --git a/src/core/server/http/integration_tests/fixtures/static/some_json.json b/src/core/server/http/integration_tests/fixtures/static/some_json.json new file mode 100644 index 0000000000000..c8c4105eb57cd --- /dev/null +++ b/src/core/server/http/integration_tests/fixtures/static/some_json.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts index 7571184363d2e..dfc47098724cc 100644 --- a/src/core/server/http/integration_tests/request.test.ts +++ b/src/core/server/http/integration_tests/request.test.ts @@ -163,24 +163,24 @@ describe('KibanaRequest', () => { describe('events', () => { describe('aborted$', () => { - it('emits once and completes when request aborted', async (done) => { + it('emits once and completes when request aborted', async () => { expect.assertions(1); const { server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); const nextSpy = jest.fn(); - router.get({ path: '/', validate: false }, async (context, request, res) => { - request.events.aborted$.subscribe({ - next: nextSpy, - complete: () => { - expect(nextSpy).toHaveBeenCalledTimes(1); - done(); - }, - }); - // prevents the server to respond - await delay(30000); - return res.ok({ body: 'ok' }); + const done = new Promise((resolve) => { + router.get({ path: '/', validate: false }, async (context, request, res) => { + request.events.aborted$.subscribe({ + next: nextSpy, + complete: resolve, + }); + + // prevents the server to respond + await delay(30000); + return res.ok({ body: 'ok' }); + }); }); await server.start(); @@ -191,6 +191,8 @@ describe('KibanaRequest', () => { .end(); setTimeout(() => incomingRequest.abort(), 50); + await done; + expect(nextSpy).toHaveBeenCalledTimes(1); }); it('completes & does not emit when request handled', async () => { @@ -299,25 +301,24 @@ describe('KibanaRequest', () => { expect(completeSpy).toHaveBeenCalledTimes(1); }); - it('emits once and completes when response is aborted', async (done) => { + it('emits once and completes when response is aborted', async () => { expect.assertions(2); const { server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); const nextSpy = jest.fn(); - router.get({ path: '/', validate: false }, async (context, req, res) => { - req.events.completed$.subscribe({ - next: nextSpy, - complete: () => { - expect(nextSpy).toHaveBeenCalledTimes(1); - done(); - }, - }); + const done = new Promise((resolve) => { + router.get({ path: '/', validate: false }, async (context, req, res) => { + req.events.completed$.subscribe({ + next: nextSpy, + complete: resolve, + }); - expect(nextSpy).not.toHaveBeenCalled(); - await delay(30000); - return res.ok({ body: 'ok' }); + expect(nextSpy).not.toHaveBeenCalled(); + await delay(30000); + return res.ok({ body: 'ok' }); + }); }); await server.start(); @@ -327,6 +328,8 @@ describe('KibanaRequest', () => { // end required to send request .end(); setTimeout(() => incomingRequest.abort(), 50); + await done; + expect(nextSpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index 14dba1db9b624..0ec6fe89de1f1 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -187,12 +187,8 @@ async function migrateSourceToDest(context: Context) { await Index.write( client, dest.indexName, - await migrateRawDocs( - serializer, - documentMigrator.migrateAndConvert, - // @ts-expect-error @elastic/elasticsearch `Hit._id` may be a string | number in ES, but we always expect strings in the SO index. - docs - ) + // @ts-expect-error @elastic/elasticsearch _source is optional + await migrateRawDocs(serializer, documentMigrator.migrateAndConvert, docs) ); } } diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index f4e0dd8fffcab..4c9e37d17f2e7 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -95,6 +95,12 @@ describe('migration v2', () => { }, ], }, + // reporting loads headless browser, that prevents nodejs process from exiting. + xpack: { + reporting: { + enabled: false, + }, + }, }, { oss, diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 6b51bd57248a1..1577f773434b9 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -897,10 +897,10 @@ export class SavedObjectsRepository { total: body.hits.total, saved_objects: body.hits.hits.map( (hit: estypes.SearchHit): SavedObjectsFindResult => ({ - // @ts-expect-error @elastic/elasticsearch declared Id as string | number + // @ts-expect-error @elastic/elasticsearch _source is optional ...this._rawToSavedObject(hit), score: hit._score!, - // @ts-expect-error @elastic/elasticsearch declared sort as string | number + // @ts-expect-error @elastic/elasticsearch _source is optional sort: hit.sort, }) ), diff --git a/src/core/server/ui_settings/integration_tests/index.test.ts b/src/core/server/ui_settings/integration_tests/index.test.ts index 6c7cdfa43cf57..61e55284a20b8 100644 --- a/src/core/server/ui_settings/integration_tests/index.test.ts +++ b/src/core/server/ui_settings/integration_tests/index.test.ts @@ -17,7 +17,7 @@ const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo. const savedObjectIndex = `.kibana_${kibanaVersion}_001`; describe('uiSettings/routes', function () { - jest.setTimeout(10000); + jest.setTimeout(120_000); beforeAll(startServers); /* eslint-disable jest/valid-describe */ diff --git a/src/core/server/ui_settings/integration_tests/lib/servers.ts b/src/core/server/ui_settings/integration_tests/lib/servers.ts index b18d9926649aa..96ba08a0728ab 100644 --- a/src/core/server/ui_settings/integration_tests/lib/servers.ts +++ b/src/core/server/ui_settings/integration_tests/lib/servers.ts @@ -75,8 +75,10 @@ export function getServices() { export async function stopServers() { services = null!; - if (servers) { + if (esServer) { await esServer.stop(); + } + if (kbn) { await kbn.stop(); } } diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index ba22ecb3b6376..2995ffd08e5c0 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -7,15 +7,7 @@ */ import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; -import { - createTestEsCluster, - DEFAULT_SUPERUSER_PASS, - esTestConfig, - kbnTestConfig, - kibanaServerTestUser, - kibanaTestUser, - setupUsers, -} from '@kbn/test'; +import { createTestEsCluster, esTestConfig, kibanaServerTestUser, kibanaTestUser } from '@kbn/test'; import { defaultsDeep } from 'lodash'; import { resolve } from 'path'; import { BehaviorSubject } from 'rxjs'; @@ -208,7 +200,6 @@ export function createTestServers({ defaultsDeep({}, settings.es ?? {}, { log, license, - password: license === 'trial' ? DEFAULT_SUPERUSER_PASS : undefined, }) ); @@ -224,19 +215,7 @@ export function createTestServers({ await es.start(); if (['gold', 'trial'].includes(license)) { - await setupUsers({ - log, - esPort: esTestConfig.getUrlParts().port, - updates: [ - ...usersToBeAdded, - // user elastic - esTestConfig.getUrlParts() as { username: string; password: string }, - // user kibana - kbnTestConfig.getUrlParts() as { username: string; password: string }, - ], - }); - - // Override provided configs, we know what the elastic user is now + // Override provided configs kbnSettings.elasticsearch = { hosts: [esTestConfig.getUrl()], username: kibanaServerTestUser.username, diff --git a/src/core/types/elasticsearch/search.ts b/src/core/types/elasticsearch/search.ts index 36a684fb097a5..0960fb189a341 100644 --- a/src/core/types/elasticsearch/search.ts +++ b/src/core/types/elasticsearch/search.ts @@ -417,7 +417,9 @@ export type AggregateOf< { key: string; from?: number; + from_as_string?: string; to?: number; + to_as_string?: string; doc_count: number; }, TAggregationContainer extends { range: { ranges: Array } } diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index ebf56166a8922..b3b7bf5e8eed7 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -10,6 +10,7 @@ // used as dependencies or dev dependencies export const LICENSE_ALLOWED = [ 'Elastic-License', + 'Elastic License 2.0', 'SSPL-1.0 OR Elastic License 2.0', '0BSD', '(BSD-2-Clause OR MIT OR Apache-2.0)', @@ -72,6 +73,7 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0']; 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.14.0': ['Elastic License 2.0'], // TODO can be removed if the https://github.com/jindw/xmldom/issues/239 is released 'xmldom@0.1.27': ['MIT'], diff --git a/src/dev/run_licenses_csv_report.js b/src/dev/run_licenses_csv_report.js index 8a612c9e3d878..1923eddff33e9 100644 --- a/src/dev/run_licenses_csv_report.js +++ b/src/dev/run_licenses_csv_report.js @@ -71,7 +71,8 @@ run( licenses: [ 'Custom;https://www.redhat.com/licenses/EULA_Red_Hat_Universal_Base_Image_English_20190422.pdf', ], - sourceURL: 'https://oss-dependencies.elastic.co/redhat/ubi/ubi-minimal-8-source.tar.gz', + sourceURL: + 'https://oss-dependencies.elastic.co/red-hat-universal-base-image-minimal/8/ubi-minimal-8-source.tar.gz', } ); diff --git a/src/plugins/charts/public/services/palettes/types.ts b/src/plugins/charts/public/services/palettes/types.ts index 6f13f62178364..7a870504270d7 100644 --- a/src/plugins/charts/public/services/palettes/types.ts +++ b/src/plugins/charts/public/services/palettes/types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Ast } from '@kbn/interpreter/common'; +import { ExpressionAstExpression } from '../../../../expressions/common/ast'; /** * Information about a series in a chart used to determine its color. @@ -78,7 +78,7 @@ export interface PaletteDefinition { * This function should be used to pass the palette to the expression function applying color and other styles * @param state The internal state of the palette */ - toExpression: (state?: T) => Ast; + toExpression: (state?: T) => ExpressionAstExpression; /** * Color a series according to the internal rules of the palette. * @param series The current series along with its ancestors. diff --git a/src/plugins/data/common/search/aggs/agg_config.ts b/src/plugins/data/common/search/aggs/agg_config.ts index 3c83b5bdf6084..9a35cf983c805 100644 --- a/src/plugins/data/common/search/aggs/agg_config.ts +++ b/src/plugins/data/common/search/aggs/agg_config.ts @@ -192,9 +192,8 @@ export class AggConfig { } else if (!this.aggConfigs.timeRange) { return; } - return moment.duration( - moment(this.aggConfigs.timeRange.to).diff(this.aggConfigs.timeRange.from) - ); + const resolvedBounds = this.aggConfigs.getResolvedTimeRange()!; + return moment.duration(moment(resolvedBounds.max).diff(resolvedBounds.min)); } return parsedTimeShift; } diff --git a/src/plugins/data/common/search/aggs/agg_configs.ts b/src/plugins/data/common/search/aggs/agg_configs.ts index 8593a0b0ed0fa..c205b46e077f0 100644 --- a/src/plugins/data/common/search/aggs/agg_configs.ts +++ b/src/plugins/data/common/search/aggs/agg_configs.ts @@ -23,7 +23,7 @@ import { IAggType } from './agg_type'; import { AggTypesRegistryStart } from './agg_types_registry'; import { AggGroupNames } from './agg_groups'; import { IndexPattern } from '../../index_patterns/index_patterns/index_pattern'; -import { TimeRange, getTime, isRangeFilter } from '../../../common'; +import { TimeRange, getTime, isRangeFilter, calculateBounds } from '../../../common'; import { IBucketAggConfig } from './buckets'; import { insertTimeShiftSplit, mergeTimeShifts } from './utils/time_splits'; @@ -127,6 +127,19 @@ export class AggConfigs { this.aggs.forEach(updateAggTimeRange); } + /** + * Returns the current time range as moment instance (date math will get resolved using the current "now" value or system time if not set) + * @returns Current time range as resolved date. + */ + getResolvedTimeRange() { + return ( + this.timeRange && + calculateBounds(this.timeRange, { + forceNow: this.forceNow, + }) + ); + } + // clone method will reuse existing AggConfig in the list (will not create new instances) clone({ enabledOnly = true } = {}) { const filterAggs = (agg: AggConfig) => { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index d56727b468da6..4d9c69b137a3e 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -259,6 +259,7 @@ export class AggConfigs { getRequestAggById(id: string): AggConfig | undefined; // (undocumented) getRequestAggs(): AggConfig[]; + getResolvedTimeRange(): import("../..").TimeRangeBounds | undefined; getResponseAggById(id: string): AggConfig | undefined; getResponseAggs(): AggConfig[]; // (undocumented) diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx index 2fd394d98281b..57a9d518f838e 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx @@ -18,7 +18,6 @@ import { createSearchSourceMock } from '../../../../../../../data/common/search/ import { IndexPattern, IndexPatternAttributes } from '../../../../../../../data/common'; import { SavedObject } from '../../../../../../../../core/types'; import { indexPatternWithTimefieldMock } from '../../../../../__mocks__/index_pattern_with_timefield'; -import { DiscoverSearchSessionManager } from '../../services/discover_search_session'; import { GetStateReturn } from '../../services/discover_state'; import { DiscoverLayoutProps } from './types'; import { SavedSearchDataSubject } from '../../services/use_saved_search'; @@ -50,11 +49,12 @@ function getProps(indexPattern: IndexPattern): DiscoverLayoutProps { indexPattern, indexPatternList, navigateTo: jest.fn(), + onChangeIndexPattern: jest.fn(), + onUpdateQuery: jest.fn(), resetQuery: jest.fn(), savedSearch: savedSearchMock, savedSearchData$: savedSearch$, savedSearchRefetch$: new Subject(), - searchSessionManager: {} as DiscoverSearchSessionManager, searchSource: searchSourceMock, services, state: { columns: [] }, diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx index 0430614d413b6..a10674323e5cb 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx @@ -36,10 +36,8 @@ import { SortPairArr } from '../../../../angular/doc_table/lib/get_sort'; import { DOC_HIDE_TIME_COLUMN_SETTING, DOC_TABLE_LEGACY, - MODIFY_COLUMNS_ON_SWITCH, SAMPLE_SIZE_SETTING, SEARCH_FIELDS_FROM_SOURCE, - SORT_DEFAULT_ORDER_SETTING, } from '../../../../../../common'; import { popularizeField } from '../../../../helpers/popularize_field'; import { DocViewFilterFn } from '../../../../doc_views/doc_views_types'; @@ -52,7 +50,6 @@ import { InspectorSession } from '../../../../../../../inspector/public'; import { DiscoverUninitialized } from '../uninitialized/uninitialized'; import { SavedSearchDataMessage } from '../../services/use_saved_search'; import { useDataGridColumns } from '../../../../helpers/use_data_grid_columns'; -import { getSwitchIndexPatternAppState } from '../../utils/get_switch_index_pattern_app_state'; import { FetchStatus } from '../../../../types'; const DocTableLegacyMemoized = React.memo(DocTableLegacy); @@ -72,26 +69,20 @@ export function DiscoverLayout({ indexPattern, indexPatternList, navigateTo, + onChangeIndexPattern, + onUpdateQuery, savedSearchRefetch$, resetQuery, savedSearchData$, savedSearch, - searchSessionManager, searchSource, services, state, stateContainer, }: DiscoverLayoutProps) { - const { - trackUiMetric, - capabilities, - indexPatterns, - data, - uiSettings: config, - filterManager, - } = services; + const { trackUiMetric, capabilities, indexPatterns, data, uiSettings, filterManager } = services; - const sampleSize = useMemo(() => config.get(SAMPLE_SIZE_SETTING), [config]); + const sampleSize = useMemo(() => uiSettings.get(SAMPLE_SIZE_SETTING), [uiSettings]); const [expandedDoc, setExpandedDoc] = useState(undefined); const [inspectorSession, setInspectorSession] = useState(undefined); const scrollableDesktop = useRef(null); @@ -121,42 +112,21 @@ export function DiscoverLayout({ }; }, [savedSearchData$, fetchState]); - const isMobile = () => { - // collapse icon isn't displayed in mobile view, use it to detect which view is displayed - return collapseIcon && !collapseIcon.current; - }; + // collapse icon isn't displayed in mobile view, use it to detect which view is displayed + const isMobile = () => collapseIcon && !collapseIcon.current; const timeField = useMemo(() => { return indexPatternsUtils.isDefault(indexPattern) ? indexPattern.timeFieldName : undefined; }, [indexPattern]); const [isSidebarClosed, setIsSidebarClosed] = useState(false); - const isLegacy = useMemo(() => services.uiSettings.get(DOC_TABLE_LEGACY), [services]); - const useNewFieldsApi = useMemo(() => !services.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [ - services, - ]); - - const unmappedFieldsConfig = useMemo( - () => ({ - showUnmappedFields: useNewFieldsApi, - }), - [useNewFieldsApi] - ); + const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); + const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); const resultState = useMemo(() => getResultState(fetchStatus, rows!), [fetchStatus, rows]); - const updateQuery = useCallback( - (_payload, isUpdate?: boolean) => { - if (isUpdate === false) { - searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); - savedSearchRefetch$.next(); - } - }, - [savedSearchRefetch$, searchSessionManager] - ); - const { columns, onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useDataGridColumns({ capabilities, - config, + config: uiSettings, indexPattern, indexPatterns, setAppState: stateContainer.setAppState, @@ -243,42 +213,8 @@ export function DiscoverLayout({ const contentCentered = resultState === 'uninitialized'; const showTimeCol = useMemo( - () => !config.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName, - [config, indexPattern.timeFieldName] - ); - - const onChangeIndexPattern = useCallback( - async (id: string) => { - const nextIndexPattern = await indexPatterns.get(id); - if (nextIndexPattern && indexPattern) { - /** - * Without resetting the fetch state, e.g. a time column would be displayed when switching - * from a index pattern without to a index pattern with time filter for a brief moment - * That's because appState is updated before savedSearchData$ - * The following line of code catches this, but should be improved - */ - savedSearchData$.next({ rows: [], state: FetchStatus.LOADING, fieldCounts: {} }); - - const nextAppState = getSwitchIndexPatternAppState( - indexPattern, - nextIndexPattern, - state.columns || [], - (state.sort || []) as SortPairArr[], - config.get(MODIFY_COLUMNS_ON_SWITCH), - config.get(SORT_DEFAULT_ORDER_SETTING) - ); - stateContainer.setAppState(nextAppState); - } - }, - [ - config, - indexPattern, - indexPatterns, - savedSearchData$, - state.columns, - state.sort, - stateContainer, - ] + () => !uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName, + [uiSettings, indexPattern.timeFieldName] ); return ( @@ -294,7 +230,7 @@ export function DiscoverLayout({ searchSource={searchSource} services={services} stateContainer={stateContainer} - updateQuery={updateQuery} + updateQuery={onUpdateQuery} />

@@ -316,7 +252,6 @@ export function DiscoverLayout({ state={state} isClosed={isSidebarClosed} trackUiMetric={trackUiMetric} - unmappedFieldsConfig={unmappedFieldsConfig} useNewFieldsApi={useNewFieldsApi} onEditRuntimeField={onEditRuntimeField} /> @@ -373,7 +308,7 @@ export function DiscoverLayout({ > >; - resetQuery: () => void; navigateTo: (url: string) => void; + onChangeIndexPattern: (id: string) => void; + onUpdateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; + resetQuery: () => void; savedSearch: SavedSearch; savedSearchData$: SavedSearchDataSubject; savedSearchRefetch$: SavedSearchRefetchSubject; - searchSessionManager: DiscoverSearchSessionManager; searchSource: ISearchSource; services: DiscoverServices; state: AppState; diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx index e60dabd1d8d8c..26a3c482e9d3c 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx @@ -8,7 +8,7 @@ import './discover_field.scss'; -import React, { useState } from 'react'; +import React, { useState, useCallback, memo } from 'react'; import { EuiPopover, EuiPopoverTitle, @@ -29,6 +29,172 @@ import { IndexPatternField, IndexPattern } from '../../../../../../../data/publi import { getFieldTypeName } from './lib/get_field_type_name'; import { DiscoverFieldDetailsFooter } from './discover_field_details_footer'; +function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; +} + +const FieldInfoIcon: React.FC = memo(() => ( + + + +)); + +const DiscoverFieldTypeIcon: React.FC<{ field: IndexPatternField }> = memo(({ field }) => ( + +)); + +const FieldName: React.FC<{ field: IndexPatternField }> = memo(({ field }) => { + const title = + field.displayName !== field.name + ? i18n.translate('discover.field.title', { + defaultMessage: '{fieldName} ({fieldDisplayName})', + values: { + fieldName: field.name, + fieldDisplayName: field.displayName, + }, + }) + : field.displayName; + + return ( + + {wrapOnDot(field.displayName)} + + ); +}); + +interface ActionButtonProps { + field: IndexPatternField; + isSelected?: boolean; + alwaysShow: boolean; + toggleDisplay: (field: IndexPatternField) => void; +} + +const ActionButton: React.FC = memo( + ({ field, isSelected, alwaysShow, toggleDisplay }) => { + const actionBtnClassName = classNames('dscSidebarItem__action', { + ['dscSidebarItem__mobile']: alwaysShow, + }); + if (field.name === '_source') { + return null; + } + if (!isSelected) { + return ( + + ) => { + if (ev.type === 'click') { + ev.currentTarget.focus(); + } + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { + defaultMessage: 'Add {field} to table', + values: { field: field.name }, + })} + /> + + ); + } else { + return ( + + ) => { + if (ev.type === 'click') { + ev.currentTarget.focus(); + } + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={i18n.translate( + 'discover.fieldChooser.discoverField.removeButtonAriaLabel', + { + defaultMessage: 'Remove {field} from table', + values: { field: field.name }, + } + )} + /> + + ); + } + } +); + +interface MultiFieldsProps { + multiFields: NonNullable; + toggleDisplay: (field: IndexPatternField) => void; + alwaysShowActionButton: boolean; +} + +const MultiFields: React.FC = memo( + ({ multiFields, toggleDisplay, alwaysShowActionButton }) => ( + + +
+ {i18n.translate('discover.fieldChooser.discoverField.multiFields', { + defaultMessage: 'Multi fields', + })} +
+
+ {multiFields.map((entry) => ( + } + fieldAction={ + + } + fieldName={} + key={entry.field.name} + /> + ))} +
+ ) +); + export interface DiscoverFieldProps { /** * Determines whether add/remove button is displayed not only when focused @@ -85,7 +251,7 @@ export interface DiscoverFieldProps { onDeleteField?: (fieldName: string) => void; } -export function DiscoverField({ +function DiscoverFieldComponent({ alwaysShowActionButton = false, field, indexPattern, @@ -99,133 +265,22 @@ export function DiscoverField({ onEditField, onDeleteField, }: DiscoverFieldProps) { - const addLabelAria = i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { - defaultMessage: 'Add {field} to table', - values: { field: field.name }, - }); - const removeLabelAria = i18n.translate( - 'discover.fieldChooser.discoverField.removeButtonAriaLabel', - { - defaultMessage: 'Remove {field} from table', - values: { field: field.name }, - } - ); - const [infoIsOpen, setOpen] = useState(false); - const toggleDisplay = (f: IndexPatternField, isSelected: boolean) => { - if (isSelected) { - onRemoveField(f.name); - } else { - onAddField(f.name); - } - }; + const toggleDisplay = useCallback( + (f: IndexPatternField) => { + if (selected) { + onRemoveField(f.name); + } else { + onAddField(f.name); + } + }, + [onAddField, onRemoveField, selected] + ); - function togglePopover() { + const togglePopover = useCallback(() => { setOpen(!infoIsOpen); - } - - function wrapOnDot(str?: string) { - // u200B is a non-width white-space character, which allows - // the browser to efficiently word-wrap right after the dot - // without us having to draw a lot of extra DOM elements, etc - return str ? str.replace(/\./g, '.\u200B') : ''; - } - - const getDscFieldIcon = (indexPatternField: IndexPatternField) => { - return ( - - ); - }; - - const dscFieldIcon = getDscFieldIcon(field); - - const getTitle = (indexPatternField: IndexPatternField) => { - return indexPatternField.displayName !== indexPatternField.name - ? i18n.translate('discover.field.title', { - defaultMessage: '{fieldName} ({fieldDisplayName})', - values: { - fieldName: indexPatternField.name, - fieldDisplayName: indexPatternField.displayName, - }, - }) - : indexPatternField.displayName; - }; - - const getFieldName = (indexPatternField: IndexPatternField) => { - return ( - - {wrapOnDot(indexPatternField.displayName)} - - ); - }; - const fieldName = getFieldName(field); - - const actionBtnClassName = classNames('dscSidebarItem__action', { - ['dscSidebarItem__mobile']: alwaysShowActionButton, - }); - const getActionButton = (f: IndexPatternField, isSelected?: boolean) => { - if (f.name !== '_source' && !isSelected) { - return ( - - ) => { - if (ev.type === 'click') { - ev.currentTarget.focus(); - } - ev.preventDefault(); - ev.stopPropagation(); - toggleDisplay(f, false); - }} - data-test-subj={`fieldToggle-${f.name}`} - aria-label={addLabelAria} - /> - - ); - } else if (f.name !== '_source' && isSelected) { - return ( - - ) => { - if (ev.type === 'click') { - ev.currentTarget.focus(); - } - ev.preventDefault(); - ev.stopPropagation(); - toggleDisplay(f, isSelected); - }} - data-test-subj={`fieldToggle-${f.name}`} - aria-label={removeLabelAria} - /> - - ); - } - }; - - const actionButton = getActionButton(field, selected); + }, [infoIsOpen]); if (field.type === '_source') { return ( @@ -233,71 +288,20 @@ export function DiscoverField({ size="s" className="dscSidebarItem" dataTestSubj={`field-${field.name}-showDetails`} - fieldIcon={dscFieldIcon} - fieldAction={actionButton} - fieldName={fieldName} + fieldIcon={} + fieldAction={ + + } + fieldName={} /> ); } - const getFieldInfoIcon = () => { - if (field.type !== 'conflict') { - return null; - } - return ( - - - - ); - }; - - const fieldInfoIcon = getFieldInfoIcon(); - - const shouldRenderMultiFields = !!multiFields; - const renderMultiFields = () => { - if (!multiFields) { - return null; - } - return ( - - -
- {i18n.translate('discover.fieldChooser.discoverField.multiFields', { - defaultMessage: 'Multi fields', - })} -
-
- {multiFields.map((entry) => ( - {}} - dataTestSubj={`field-${entry.field.name}-showDetails`} - fieldIcon={getDscFieldIcon(entry.field)} - fieldAction={getActionButton(entry.field, entry.isSelected)} - fieldName={getFieldName(entry.field)} - key={entry.field.name} - /> - ))} -
- ); - }; - const isRuntimeField = Boolean(indexPattern.getFieldByName(field.name)?.runtimeField); const isUnknownField = field.type === 'unknown' || field.type === 'unknown_selected'; const canEditField = onEditField && (!isUnknownField || isRuntimeField); @@ -334,9 +338,7 @@ export function DiscoverField({ > { - if (onDeleteField) { - onDeleteField(field.name); - } + onDeleteField?.(field.name); }} iconType="trash" data-test-subj={`discoverFieldListPanelDelete-${field.name}`} @@ -352,6 +354,8 @@ export function DiscoverField({ ); + const details = getDetails(field); + return ( { - togglePopover(); - }} + onClick={togglePopover} dataTestSubj={`field-${field.name}-showDetails`} - fieldIcon={dscFieldIcon} - fieldAction={actionButton} - fieldName={fieldName} - fieldInfoIcon={fieldInfoIcon} + fieldIcon={} + fieldAction={ + + } + fieldName={} + fieldInfoIcon={field.type === 'conflict' && } /> } isOpen={infoIsOpen} @@ -384,26 +393,33 @@ export function DiscoverField({

{infoIsOpen && ( - - )} - {shouldRenderMultiFields ? ( <> - {renderMultiFields()} - + {multiFields && ( + + )} + {!details.error && ( + + )} - ) : null} + )} ); } + +export const DiscoverField = memo(DiscoverFieldComponent); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx index d7008ba3e310f..ffa7b30de5280 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx @@ -20,7 +20,6 @@ import { import { Bucket, FieldDetails } from './types'; import { IndexPatternField, IndexPattern } from '../../../../../../../data/public'; import './discover_field_details.scss'; -import { DiscoverFieldDetailsFooter } from './discover_field_details_footer'; interface DiscoverFieldDetailsProps { field: IndexPatternField; @@ -28,7 +27,6 @@ interface DiscoverFieldDetailsProps { details: FieldDetails; onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; - showFooter?: boolean; } export function DiscoverFieldDetails({ @@ -37,7 +35,6 @@ export function DiscoverFieldDetails({ details, onAddFilter, trackUiMetric, - showFooter = true, }: DiscoverFieldDetailsProps) { const warnings = getWarnings(field); const [showVisualizeLink, setShowVisualizeLink] = useState(false); @@ -111,14 +108,6 @@ export function DiscoverFieldDetails({ )} - {!details.error && showFooter && ( - - )} ); } diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx index 0bebec61657b4..7f8866a2ee369 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx @@ -21,6 +21,7 @@ import { EuiPageSideBar, useResizeObserver, } from '@elastic/eui'; +import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect'; import { isEqual, sortBy } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -81,7 +82,6 @@ export function DiscoverSidebar({ trackUiMetric, useNewFieldsApi = false, useFlyout = false, - unmappedFieldsConfig, onEditRuntimeField, onChangeIndexPattern, setFieldEditorRef, @@ -128,25 +128,8 @@ export function DiscoverSidebar({ popular: popularFields, unpopular: unpopularFields, } = useMemo( - () => - groupFields( - fields, - columns, - popularLimit, - fieldCounts, - fieldFilter, - useNewFieldsApi, - !!unmappedFieldsConfig?.showUnmappedFields - ), - [ - fields, - columns, - popularLimit, - fieldCounts, - fieldFilter, - useNewFieldsApi, - unmappedFieldsConfig?.showUnmappedFields, - ] + () => groupFields(fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi), + [fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi] ); const paginate = useCallback(() => { @@ -205,7 +188,7 @@ export function DiscoverSidebar({ return result; }, [fields]); - const multiFields = useMemo(() => { + const calculateMultiFields = () => { if (!useNewFieldsApi || !fields) { return undefined; } @@ -224,7 +207,13 @@ export function DiscoverSidebar({ map.set(parent, value); }); return map; - }, [fields, useNewFieldsApi, selectedFields]); + }; + + const [multiFields, setMultiFields] = useState(() => calculateMultiFields()); + + useShallowCompareEffect(() => { + setMultiFields(calculateMultiFields()); + }, [fields, selectedFields, useNewFieldsApi]); const deleteField = useMemo( () => diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx index 2ad75806173eb..6973221fd3624 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx @@ -25,7 +25,6 @@ import { } from './discover_sidebar_responsive'; import { DiscoverServices } from '../../../../../build_services'; import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; -import { DiscoverSidebar } from './discover_sidebar'; const mockServices = ({ history: () => ({ @@ -132,14 +131,4 @@ describe('discover responsive sidebar', function () { findTestSubject(comp, 'plus-extension-gif').simulate('click'); expect(props.onAddFilter).toHaveBeenCalled(); }); - it('renders sidebar with unmapped fields config', function () { - const unmappedFieldsConfig = { - showUnmappedFields: false, - }; - const componentProps = { ...props, unmappedFieldsConfig }; - const component = mountWithIntl(); - const discoverSidebar = component.find(DiscoverSidebar); - expect(discoverSidebar).toHaveLength(1); - expect(discoverSidebar.props().unmappedFieldsConfig).toEqual(unmappedFieldsConfig); - }); }); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx index cc33601f77728..003bb22599e48 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx @@ -105,15 +105,6 @@ export interface DiscoverSidebarResponsiveProps { * Read from the Fields API */ useNewFieldsApi?: boolean; - /** - * an object containing properties for proper handling of unmapped fields - */ - unmappedFieldsConfig?: { - /** - * determines whether to display unmapped fields - */ - showUnmappedFields: boolean; - }; /** * callback to execute on edit runtime field */ diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts index 5869720635621..cd9f6b3cac4a5 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts @@ -244,8 +244,7 @@ describe('group_fields', function () { 5, fieldCounts, fieldFilterState, - true, - false + true ); expect(actual.unpopular).toEqual([]); }); @@ -270,8 +269,7 @@ describe('group_fields', function () { 5, fieldCounts, fieldFilterState, - false, - undefined + false ); expect(actual.unpopular.map((field) => field.name)).toEqual(['unknown_field']); }); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx index dc6cbcedc8086..2007d32fe84be 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx @@ -24,9 +24,9 @@ export function groupFields( popularLimit: number, fieldCounts: Record, fieldFilterState: FieldFilterState, - useNewFieldsApi: boolean, - showUnmappedFields = true + useNewFieldsApi: boolean ): GroupedFields { + const showUnmappedFields = useNewFieldsApi; const result: GroupedFields = { selected: [], popular: [], diff --git a/src/plugins/discover/public/application/apps/main/discover_main_app.tsx b/src/plugins/discover/public/application/apps/main/discover_main_app.tsx index 5cc7147b49ff9..07939fff6e7f4 100644 --- a/src/plugins/discover/public/application/apps/main/discover_main_app.tsx +++ b/src/plugins/discover/public/application/apps/main/discover_main_app.tsx @@ -5,15 +5,12 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useMemo, useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { History } from 'history'; import { DiscoverLayout } from './components/layout'; -import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; -import { useSavedSearch as useSavedSearchData } from './services/use_saved_search'; import { setBreadcrumbsTitle } from '../../helpers/breadcrumbs'; import { addHelpMenuToAppChrome } from '../../components/help_menu/help_menu_util'; import { useDiscoverState } from './services/use_discover_state'; -import { useSearchSession } from './services/use_search_session'; import { useUrl } from './services/use_url'; import { IndexPattern, IndexPatternAttributes, SavedObject } from '../../../../../data/common'; import { DiscoverServices } from '../../../build_services'; @@ -55,18 +52,20 @@ export function DiscoverMainApp(props: DiscoverMainProps) { const { services, history, navigateTo, indexPatternList } = props.opts; const { chrome, docLinks, uiSettings: config, data } = services; - const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]); - /** * State related logic */ const { - stateContainer, - state, + data$, indexPattern, - searchSource, - savedSearch, + onChangeIndexPattern, + onUpdateQuery, + refetch$, resetSavedSearch, + savedSearch, + searchSource, + state, + stateContainer, } = useDiscoverState({ services, history, @@ -79,25 +78,6 @@ export function DiscoverMainApp(props: DiscoverMainProps) { */ useUrl({ history, resetSavedSearch }); - /** - * Search session logic - */ - const searchSessionManager = useSearchSession({ services, history, stateContainer, savedSearch }); - - /** - * Data fetching logic - */ - const { data$, refetch$ } = useSavedSearchData({ - indexPattern, - savedSearch, - searchSessionManager, - searchSource, - services, - state, - stateContainer, - useNewFieldsApi, - }); - /** * SavedSearch depended initializing */ @@ -115,11 +95,6 @@ export function DiscoverMainApp(props: DiscoverMainProps) { */ useEffect(() => { addHelpMenuToAppChrome(chrome, docLinks); - stateContainer.replaceUrlAppState({}).then(() => { - stateContainer.startSync(); - }); - - return () => stateContainer.stopSync(); }, [stateContainer, chrome, docLinks]); const resetQuery = useCallback(() => { @@ -130,12 +105,13 @@ export function DiscoverMainApp(props: DiscoverMainProps) { ; + /** + * Function starting state sync when Discover main is loaded + */ + initializeAndSync: ( + indexPattern: IndexPattern, + filterManager: FilterManager, + data: DataPublicPluginStart + ) => () => void; /** * Start sync between state and URL */ @@ -204,16 +216,18 @@ export function getState({ stateStorage, }); + const replaceUrlAppState = async (newPartial: AppState = {}) => { + const state = { ...appStateContainer.getState(), ...newPartial }; + await stateStorage.set(APP_STATE_URL_KEY, state, { replace: true }); + }; + return { kbnUrlStateStorage: stateStorage, appStateContainer: appStateContainerModified, startSync: start, stopSync: stop, setAppState: (newPartial: AppState) => setState(appStateContainerModified, newPartial), - replaceUrlAppState: async (newPartial: AppState = {}) => { - const state = { ...appStateContainer.getState(), ...newPartial }; - await stateStorage.set(APP_STATE_URL_KEY, state, { replace: true }); - }, + replaceUrlAppState, resetInitialAppState: () => { initialAppState = appStateContainer.getState(); }, @@ -224,6 +238,50 @@ export function getState({ getPreviousAppState: () => previousAppState, flushToUrl: () => stateStorage.kbnUrlControls.flush(), isAppStateDirty: () => !isEqualState(initialAppState, appStateContainer.getState()), + initializeAndSync: ( + indexPattern: IndexPattern, + filterManager: FilterManager, + data: DataPublicPluginStart + ) => { + if (appStateContainer.getState().index !== indexPattern.id) { + // used index pattern is different than the given by url/state which is invalid + setState(appStateContainerModified, { index: indexPattern.id }); + } + // sync initial app filters from state to filterManager + const filters = appStateContainer.getState().filters; + if (filters) { + filterManager.setAppFilters(cloneDeep(filters)); + } + const query = appStateContainer.getState().query; + if (query) { + data.query.queryString.setQuery(query); + } + + const stopSyncingQueryAppStateWithStateContainer = connectToQueryState( + data.query, + appStateContainer, + { + filters: esFilters.FilterStateStore.APP_STATE, + query: true, + } + ); + + // syncs `_g` portion of url with query services + const { stop: stopSyncingGlobalStateWithUrl } = syncQueryStateWithUrl( + data.query, + stateStorage + ); + + replaceUrlAppState({}).then(() => { + start(); + }); + + return () => { + stopSyncingQueryAppStateWithStateContainer(); + stopSyncingGlobalStateWithUrl(); + stop(); + }; + }, }; } diff --git a/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts b/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts index 051a2d2dcd9cc..4c3d819f063a0 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts @@ -62,10 +62,6 @@ describe('test useDiscoverState', () => { }); }); - await act(async () => { - result.current.stateContainer.startSync(); - }); - const initialColumns = result.current.state.columns; await act(async () => { result.current.setState({ columns: ['123'] }); diff --git a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts index a3546d54cd493..3c736f09a8296 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts @@ -6,19 +6,25 @@ * Side Public License, v 1. */ import { useMemo, useEffect, useState, useCallback } from 'react'; -import { cloneDeep } from 'lodash'; +import { isEqual } from 'lodash'; import { History } from 'history'; import { getState } from './discover_state'; import { getStateDefaults } from '../utils/get_state_defaults'; -import { - esFilters, - connectToQueryState, - syncQueryStateWithUrl, - IndexPattern, -} from '../../../../../../data/public'; +import { IndexPattern } from '../../../../../../data/public'; import { DiscoverServices } from '../../../../build_services'; import { SavedSearch } from '../../../../saved_searches'; import { loadIndexPattern } from '../utils/resolve_index_pattern'; +import { useSavedSearch as useSavedSearchData } from './use_saved_search'; +import { + MODIFY_COLUMNS_ON_SWITCH, + SEARCH_FIELDS_FROM_SOURCE, + SEARCH_ON_PAGE_LOAD_SETTING, + SORT_DEFAULT_ORDER_SETTING, +} from '../../../../../common'; +import { useSearchSession } from './use_search_session'; +import { FetchStatus } from '../../../types'; +import { getSwitchIndexPatternAppState } from '../utils/get_switch_index_pattern_app_state'; +import { SortPairArr } from '../../../angular/doc_table/lib/get_sort'; export function useDiscoverState({ services, @@ -31,9 +37,11 @@ export function useDiscoverState({ history: History; initialIndexPattern: IndexPattern; }) { - const { uiSettings: config, data, filterManager } = services; + const { uiSettings: config, data, filterManager, indexPatterns } = services; const [indexPattern, setIndexPattern] = useState(initialIndexPattern); const [savedSearch, setSavedSearch] = useState(initialSavedSearch); + const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]); + const timefilter = data.query.timefilter.timefilter; const searchSource = useMemo(() => { savedSearch.searchSource.setField('index', indexPattern); @@ -57,73 +65,80 @@ export function useDiscoverState({ [config, data, history, savedSearch, services.core.notifications.toasts] ); - const { appStateContainer, getPreviousAppState } = stateContainer; + const { appStateContainer } = stateContainer; const [state, setState] = useState(appStateContainer.getState()); - useEffect(() => { - if (stateContainer.appStateContainer.getState().index !== indexPattern.id) { - // used index pattern is different than the given by url/state which is invalid - stateContainer.setAppState({ index: indexPattern.id }); - } - // sync initial app filters from state to filterManager - const filters = appStateContainer.getState().filters; - if (filters) { - filterManager.setAppFilters(cloneDeep(filters)); - } - const query = appStateContainer.getState().query; - if (query) { - data.query.queryString.setQuery(query); - } + /** + * Search session logic + */ + const searchSessionManager = useSearchSession({ services, history, stateContainer, savedSearch }); - const stopSyncingQueryAppStateWithStateContainer = connectToQueryState( - data.query, - appStateContainer, - { - filters: esFilters.FilterStateStore.APP_STATE, - query: true, - } - ); + const initialFetchStatus: FetchStatus = useMemo(() => { + // A saved search is created on every page load, so we check the ID to see if we're loading a + // previously saved search or if it is just transient + const shouldSearchOnPageLoad = + config.get(SEARCH_ON_PAGE_LOAD_SETTING) || + savedSearch.id !== undefined || + timefilter.getRefreshInterval().pause === false || + searchSessionManager.hasSearchSessionIdInURL(); + return shouldSearchOnPageLoad ? FetchStatus.LOADING : FetchStatus.UNINITIALIZED; + }, [config, savedSearch.id, searchSessionManager, timefilter]); - // syncs `_g` portion of url with query services - const { stop: stopSyncingGlobalStateWithUrl } = syncQueryStateWithUrl( - data.query, - stateContainer.kbnUrlStateStorage - ); + /** + * Data fetching logic + */ + const { data$, refetch$, reset } = useSavedSearchData({ + indexPattern, + initialFetchStatus, + searchSessionManager, + searchSource, + services, + stateContainer, + useNewFieldsApi, + }); + + useEffect(() => { + const stopSync = stateContainer.initializeAndSync(indexPattern, filterManager, data); return () => { - stopSyncingQueryAppStateWithStateContainer(); - stopSyncingGlobalStateWithUrl(); + stopSync(); }; - }, [ - appStateContainer, - config, - data.query, - data.search.session, - getPreviousAppState, - indexPattern.id, - filterManager, - services.indexPatterns, - stateContainer, - ]); + }, [stateContainer, filterManager, data, indexPattern]); + /** + * Track state changes that should trigger a fetch + */ useEffect(() => { - const unsubscribe = stateContainer.appStateContainer.subscribe(async (nextState) => { + const unsubscribe = appStateContainer.subscribe(async (nextState) => { + const { hideChart, interval, sort, index } = state; + // chart was hidden, now it should be displayed, so data is needed + const chartDisplayChanged = nextState.hideChart !== hideChart && hideChart; + const chartIntervalChanged = nextState.interval !== interval; + const docTableSortChanged = !isEqual(nextState.sort, sort); + const indexPatternChanged = !isEqual(nextState.index, index); // NOTE: this is also called when navigating from discover app to context app - if (nextState.index && state.index !== nextState.index) { - const nextIndexPattern = await loadIndexPattern( - nextState.index, - services.indexPatterns, - config - ); + if (nextState.index && indexPatternChanged) { + /** + * Without resetting the fetch state, e.g. a time column would be displayed when switching + * from a index pattern without to a index pattern with time filter for a brief moment + * That's because appState is updated before savedSearchData$ + * The following line of code catches this, but should be improved + */ + reset(); + const nextIndexPattern = await loadIndexPattern(nextState.index, indexPatterns, config); if (nextIndexPattern) { setIndexPattern(nextIndexPattern.loaded); } } + + if (chartDisplayChanged || chartIntervalChanged || docTableSortChanged) { + refetch$.next(); + } setState(nextState); }); return () => unsubscribe(); - }, [config, services.indexPatterns, state.index, stateContainer.appStateContainer, setState]); + }, [config, indexPatterns, appStateContainer, setState, state, refetch$, data$, reset]); const resetSavedSearch = useCallback( async (id?: string) => { @@ -143,13 +158,62 @@ export function useDiscoverState({ [services, indexPattern, config, data, stateContainer, savedSearch.id] ); + /** + * Function triggered when user changes index pattern in the sidebar + */ + const onChangeIndexPattern = useCallback( + async (id: string) => { + const nextIndexPattern = await indexPatterns.get(id); + if (nextIndexPattern && indexPattern) { + const nextAppState = getSwitchIndexPatternAppState( + indexPattern, + nextIndexPattern, + state.columns || [], + (state.sort || []) as SortPairArr[], + config.get(MODIFY_COLUMNS_ON_SWITCH), + config.get(SORT_DEFAULT_ORDER_SETTING) + ); + stateContainer.setAppState(nextAppState); + } + }, + [config, indexPattern, indexPatterns, state.columns, state.sort, stateContainer] + ); + /** + * Function triggered when the user changes the query in the search bar + */ + const onUpdateQuery = useCallback( + (_payload, isUpdate?: boolean) => { + if (isUpdate === false) { + searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); + refetch$.next(); + } + }, + [refetch$, searchSessionManager] + ); + + /** + * Initial data fetching, also triggered when index pattern changes + */ + useEffect(() => { + if (!indexPattern) { + return; + } + if (initialFetchStatus === FetchStatus.LOADING) { + refetch$.next(); + } + }, [initialFetchStatus, refetch$, indexPattern, data$]); + return { - state, - setState, - stateContainer, + data$, indexPattern, - searchSource, - savedSearch, + refetch$, resetSavedSearch, + onChangeIndexPattern, + onUpdateQuery, + savedSearch, + searchSource, + setState, + state, + stateContainer, }; } diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts index 5976c8fea5ea4..128c94f284f56 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts @@ -12,9 +12,10 @@ import { discoverServiceMock } from '../../../../__mocks__/services'; import { savedSearchMock } from '../../../../__mocks__/saved_search'; import { indexPatternMock } from '../../../../__mocks__/index_pattern'; import { useSavedSearch } from './use_saved_search'; -import { AppState, getState } from './discover_state'; +import { getState } from './discover_state'; import { uiSettingsMock } from '../../../../__mocks__/ui_settings'; import { useDiscoverState } from './use_discover_state'; +import { FetchStatus } from '../../../types'; describe('test useSavedSearch', () => { test('useSavedSearch return is valid', async () => { @@ -28,11 +29,10 @@ describe('test useSavedSearch', () => { const { result } = renderHook(() => { return useSavedSearch({ indexPattern: indexPatternMock, - savedSearch: savedSearchMock, + initialFetchStatus: FetchStatus.LOADING, searchSessionManager, searchSource: savedSearchMock.searchSource.createCopy(), services: discoverServiceMock, - state: {} as AppState, stateContainer, useNewFieldsApi: true, }); @@ -69,11 +69,10 @@ describe('test useSavedSearch', () => { const { result, waitForValueToChange } = renderHook(() => { return useSavedSearch({ indexPattern: indexPatternMock, - savedSearch: savedSearchMock, + initialFetchStatus: FetchStatus.LOADING, searchSessionManager, searchSource: resultState.current.searchSource, services: discoverServiceMock, - state: {} as AppState, stateContainer, useNewFieldsApi: true, }); @@ -88,4 +87,47 @@ describe('test useSavedSearch', () => { expect(result.current.data$.value.hits).toBe(0); expect(result.current.data$.value.rows).toEqual([]); }); + + test('reset sets back to initial state', async () => { + const { history, searchSessionManager } = createSearchSessionMock(); + const stateContainer = getState({ + getStateDefaults: () => ({ index: 'the-index-pattern-id' }), + history, + uiSettings: uiSettingsMock, + }); + + discoverServiceMock.data.query.timefilter.timefilter.getTime = jest.fn(() => { + return { from: '2021-05-01T20:00:00Z', to: '2021-05-02T20:00:00Z' }; + }); + + const { result: resultState } = renderHook(() => { + return useDiscoverState({ + services: discoverServiceMock, + history, + initialIndexPattern: indexPatternMock, + initialSavedSearch: savedSearchMock, + }); + }); + + const { result, waitForValueToChange } = renderHook(() => { + return useSavedSearch({ + indexPattern: indexPatternMock, + initialFetchStatus: FetchStatus.LOADING, + searchSessionManager, + searchSource: resultState.current.searchSource, + services: discoverServiceMock, + stateContainer, + useNewFieldsApi: true, + }); + }); + + result.current.refetch$.next(); + + await waitForValueToChange(() => { + return result.current.data$.value.state === FetchStatus.COMPLETE; + }); + + result.current.reset(); + expect(result.current.data$.value.state).toBe(FetchStatus.LOADING); + }); }); diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts index 2b0d951724869..8c847b54078eb 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts @@ -5,11 +5,10 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { useEffect, useRef, useCallback, useMemo } from 'react'; +import { useEffect, useRef, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { merge, Subject, BehaviorSubject } from 'rxjs'; import { debounceTime, tap, filter } from 'rxjs/operators'; -import { isEqual } from 'lodash'; import { DiscoverServices } from '../../../../build_services'; import { DiscoverSearchSessionManager } from './discover_search_session'; import { @@ -18,13 +17,11 @@ import { SearchSource, tabifyAggResponse, } from '../../../../../../data/common'; -import { SavedSearch } from '../../../../saved_searches'; -import { AppState, GetStateReturn } from './discover_state'; +import { GetStateReturn } from './discover_state'; import { ElasticSearchHit } from '../../../doc_views/doc_views_types'; import { RequestAdapter } from '../../../../../../inspector/public'; import { AutoRefreshDoneFn, search } from '../../../../../../data/public'; import { calcFieldCounts } from '../utils/calc_field_counts'; -import { SEARCH_ON_PAGE_LOAD_SETTING } from '../../../../../common'; import { validateTimeRange } from '../utils/validate_time_range'; import { updateSearchSource } from '../utils/update_search_source'; import { SortOrder } from '../../../../saved_searches/types'; @@ -40,6 +37,7 @@ export type SavedSearchRefetchSubject = Subject; export interface UseSavedSearch { refetch$: SavedSearchRefetchSubject; data$: SavedSearchDataSubject; + reset: () => void; } export type SavedSearchRefetchMsg = 'reset' | undefined; @@ -59,48 +57,27 @@ export interface SavedSearchDataMessage { /** * This hook return 2 observables, refetch$ allows to trigger data fetching, data$ to subscribe * to the data fetching - * @param indexPattern - * @param savedSearch - * @param searchSessionManager - * @param searchSource - * @param services - * @param state - * @param stateContainer - * @param useNewFieldsApi */ export const useSavedSearch = ({ indexPattern, - savedSearch, + initialFetchStatus, searchSessionManager, searchSource, services, - state, stateContainer, useNewFieldsApi, }: { indexPattern: IndexPattern; - savedSearch: SavedSearch; + initialFetchStatus: FetchStatus; searchSessionManager: DiscoverSearchSessionManager; searchSource: SearchSource; services: DiscoverServices; - state: AppState; stateContainer: GetStateReturn; useNewFieldsApi: boolean; }): UseSavedSearch => { - const { data, filterManager, uiSettings } = services; + const { data, filterManager } = services; const timefilter = data.query.timefilter.timefilter; - const initFetchState: FetchStatus = useMemo(() => { - // A saved search is created on every page load, so we check the ID to see if we're loading a - // previously saved search or if it is just transient - const shouldSearchOnPageLoad = - uiSettings.get(SEARCH_ON_PAGE_LOAD_SETTING) || - savedSearch.id !== undefined || - timefilter.getRefreshInterval().pause === false || - searchSessionManager.hasSearchSessionIdInURL(); - return shouldSearchOnPageLoad ? FetchStatus.LOADING : FetchStatus.UNINITIALIZED; - }, [uiSettings, savedSearch.id, searchSessionManager, timefilter]); - /** * The observable the UI (aka React component) subscribes to get notified about * the changes in the data fetching process (high level: fetching started, data was received) @@ -108,7 +85,7 @@ export const useSavedSearch = ({ const data$: SavedSearchDataSubject = useSingleton( () => new BehaviorSubject({ - state: initFetchState, + state: initialFetchStatus, }) ); /** @@ -123,15 +100,14 @@ export const useSavedSearch = ({ */ const refs = useRef<{ abortController?: AbortController; - /** - * used to compare a new state against an old one, to evaluate if data needs to be fetched - */ - appState: AppState; /** * handler emitted by `timefilter.getAutoRefreshFetch$()` * to notify when data completed loading and to start a new autorefresh loop */ autoRefreshDoneCb?: AutoRefreshDoneFn; + /** + * Number of fetches used for functional testing + */ fetchCounter: number; /** * needed to right auto refresh behavior, a new auto refresh shouldnt be triggered when @@ -144,12 +120,34 @@ export const useSavedSearch = ({ */ fieldCounts: Record; }>({ - appState: state, fetchCounter: 0, fieldCounts: {}, - fetchStatus: initFetchState, + fetchStatus: initialFetchStatus, }); + /** + * Resets the fieldCounts cache and sends a reset message + * It is set to initial state (no documents, fetchCounter to 0) + * Needed when index pattern is switched or a new runtime field is added + */ + const sendResetMsg = useCallback( + (fetchStatus?: FetchStatus) => { + refs.current.fieldCounts = {}; + refs.current.fetchStatus = fetchStatus ?? initialFetchStatus; + data$.next({ + state: initialFetchStatus, + fetchCounter: 0, + rows: [], + fieldCounts: {}, + chartData: undefined, + bucketInterval: undefined, + }); + }, + [data$, initialFetchStatus] + ); + /** + * Function to fetch data from ElasticSearch + */ const fetchAll = useCallback( (reset = false) => { if (!validateTimeRange(timefilter.getTime(), services.toastNotifications)) { @@ -161,23 +159,18 @@ export const useSavedSearch = ({ refs.current.abortController = new AbortController(); const sessionId = searchSessionManager.getNextSearchSessionId(); - // Let the UI know, data fetching started - const loadingMessage: SavedSearchDataMessage = { - state: FetchStatus.LOADING, - fetchCounter: ++refs.current.fetchCounter, - }; - if (reset) { - // when runtime field was added, changed, deleted, index pattern was switched - loadingMessage.rows = []; - loadingMessage.fieldCounts = {}; - loadingMessage.chartData = undefined; - loadingMessage.bucketInterval = undefined; + sendResetMsg(FetchStatus.LOADING); + } else { + // Let the UI know, data fetching started + data$.next({ + state: FetchStatus.LOADING, + fetchCounter: ++refs.current.fetchCounter, + }); + refs.current.fetchStatus = FetchStatus.LOADING; } - data$.next(loadingMessage); - refs.current.fetchStatus = loadingMessage.state; - const { sort } = stateContainer.appStateContainer.getState(); + const { sort, hideChart, interval } = stateContainer.appStateContainer.getState(); updateSearchSource(searchSource, false, { indexPattern, services, @@ -185,8 +178,8 @@ export const useSavedSearch = ({ useNewFieldsApi, }); const chartAggConfigs = - indexPattern.timeFieldName && !state.hideChart && state.interval - ? getChartAggConfigs(searchSource, state.interval, data) + indexPattern.timeFieldName && !hideChart && interval + ? getChartAggConfigs(searchSource, interval, data) : undefined; if (!chartAggConfigs) { @@ -217,16 +210,12 @@ export const useSavedSearch = ({ state: FetchStatus.COMPLETE, rows: documents, inspectorAdapters, - fieldCounts: calcFieldCounts( - reset ? {} : refs.current.fieldCounts, - documents, - indexPattern - ), + fieldCounts: calcFieldCounts(refs.current.fieldCounts, documents, indexPattern), hits: res.rawResponse.hits.total as number, }; if (chartAggConfigs) { - const bucketAggConfig = chartAggConfigs!.aggs[1]; + const bucketAggConfig = chartAggConfigs.aggs[1]; const tabifiedData = tabifyAggResponse(chartAggConfigs, res.rawResponse); const dimensions = getDimensions(chartAggConfigs, data); if (dimensions) { @@ -259,14 +248,13 @@ export const useSavedSearch = ({ [ timefilter, services, + searchSessionManager, stateContainer.appStateContainer, searchSource, indexPattern, useNewFieldsApi, - state.hideChart, - state.interval, data, - searchSessionManager, + sendResetMsg, data$, ] ); @@ -306,32 +294,9 @@ export const useSavedSearch = ({ fetchAll, ]); - /** - * Track state changes that should trigger a fetch - */ - useEffect(() => { - const prevAppState = refs.current.appState; - - // chart was hidden, now it should be displayed, so data is needed - const chartDisplayChanged = state.hideChart !== prevAppState.hideChart && !state.hideChart; - const chartIntervalChanged = state.interval !== prevAppState.interval; - const docTableSortChanged = !isEqual(state.sort, prevAppState.sort); - const indexPatternChanged = !isEqual(state.index, prevAppState.index); - - refs.current.appState = state; - if (chartDisplayChanged || chartIntervalChanged || docTableSortChanged || indexPatternChanged) { - refetch$.next(indexPatternChanged ? 'reset' : undefined); - } - }, [refetch$, state.interval, state.sort, state]); - - useEffect(() => { - if (initFetchState === FetchStatus.LOADING) { - refetch$.next(); - } - }, [initFetchState, refetch$]); - return { refetch$, data$, + reset: sendResetMsg, }; }; diff --git a/src/plugins/discover/public/application/components/doc/doc.tsx b/src/plugins/discover/public/application/components/doc/doc.tsx index e38709b465174..ed8bcf30d2bd1 100644 --- a/src/plugins/discover/public/application/components/doc/doc.tsx +++ b/src/plugins/discover/public/application/components/doc/doc.tsx @@ -10,9 +10,10 @@ import React from 'react'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent, EuiPage } from '@elastic/eui'; import { IndexPatternsContract } from 'src/plugins/data/public'; -import { ElasticRequestState, useEsDocSearch } from './use_es_doc_search'; +import { useEsDocSearch } from './use_es_doc_search'; import { getServices } from '../../../kibana_services'; import { DocViewer } from '../doc_viewer/doc_viewer'; +import { ElasticRequestState } from './elastic_request_state'; export interface DocProps { /** @@ -32,6 +33,10 @@ export interface DocProps { * IndexPatternService to get a given index pattern by ID */ indexPatternService: IndexPatternsContract; + /** + * If set, will always request source, regardless of the global `fieldsFromSource` setting + */ + requestSource?: boolean; } export function Doc(props: DocProps) { diff --git a/packages/kbn-storybook/typings.d.ts b/src/plugins/discover/public/application/components/doc/elastic_request_state.ts similarity index 75% rename from packages/kbn-storybook/typings.d.ts rename to src/plugins/discover/public/application/components/doc/elastic_request_state.ts index b940de2829909..241e37c47a7e7 100644 --- a/packages/kbn-storybook/typings.d.ts +++ b/src/plugins/discover/public/application/components/doc/elastic_request_state.ts @@ -6,5 +6,10 @@ * Side Public License, v 1. */ -// Storybook react doesn't declare this in its typings, but it's there. -declare module '@storybook/react/standalone'; +export enum ElasticRequestState { + Loading, + NotFound, + Found, + Error, + NotFoundIndexPattern, +} diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx index f3a6b274649f5..9fdb564cb518d 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx @@ -7,11 +7,12 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { buildSearchBody, useEsDocSearch, ElasticRequestState } from './use_es_doc_search'; +import { buildSearchBody, useEsDocSearch } from './use_es_doc_search'; import { DocProps } from './doc'; import { Observable } from 'rxjs'; import { SEARCH_FIELDS_FROM_SOURCE as mockSearchFieldsFromSource } from '../../../../common'; import { IndexPattern } from 'src/plugins/data/common'; +import { ElasticRequestState } from './elastic_request_state'; const mockSearchResult = new Observable(); @@ -88,6 +89,36 @@ describe('Test of helper / hook', () => { `); }); + test('buildSearchBody with requestSource', () => { + const indexPattern = ({ + getComputedFields: () => ({ storedFields: [], scriptFields: [], docvalueFields: [] }), + } as unknown) as IndexPattern; + const actual = buildSearchBody('1', indexPattern, true, true); + expect(actual).toMatchInlineSnapshot(` + Object { + "body": Object { + "_source": true, + "fields": Array [ + Object { + "field": "*", + "include_unmapped": "true", + }, + ], + "query": Object { + "ids": Object { + "values": Array [ + "1", + ], + }, + }, + "runtime_mappings": Object {}, + "script_fields": Array [], + "stored_fields": Array [], + }, + } + `); + }); + test('buildSearchBody with runtime fields', () => { const indexPattern = ({ getComputedFields: () => ({ @@ -155,7 +186,11 @@ describe('Test of helper / hook', () => { await act(async () => { hook = renderHook((p: DocProps) => useEsDocSearch(p), { initialProps: props }); }); - expect(hook.result.current).toEqual([ElasticRequestState.Loading, null, indexPattern]); + expect(hook.result.current.slice(0, 3)).toEqual([ + ElasticRequestState.Loading, + null, + indexPattern, + ]); expect(getMock).toHaveBeenCalled(); }); }); diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts index 7a3320d43c8b5..71a32b758aca7 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts @@ -6,23 +6,16 @@ * Side Public License, v 1. */ -import { useEffect, useState, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { estypes } from '@elastic/elasticsearch'; -import { IndexPattern, getServices } from '../../../kibana_services'; +import { getServices, IndexPattern } from '../../../kibana_services'; import { DocProps } from './doc'; import { ElasticSearchHit } from '../../doc_views/doc_views_types'; import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; +import { ElasticRequestState } from './elastic_request_state'; type RequestBody = Pick; -export enum ElasticRequestState { - Loading, - NotFound, - Found, - Error, - NotFoundIndexPattern, -} - /** * helper function to build a query body for Elasticsearch * https://www.elastic.co/guide/en/elasticsearch/reference/current//query-dsl-ids-query.html @@ -30,7 +23,8 @@ export enum ElasticRequestState { export function buildSearchBody( id: string, indexPattern: IndexPattern, - useNewFieldsApi: boolean + useNewFieldsApi: boolean, + requestAllFields?: boolean ): RequestBody | undefined { const computedFields = indexPattern.getComputedFields(); const runtimeFields = computedFields.runtimeFields as estypes.MappingRuntimeFields; @@ -52,6 +46,9 @@ export function buildSearchBody( // @ts-expect-error request.body.fields = [{ field: '*', include_unmapped: 'true' }]; request.body.runtime_mappings = runtimeFields ? runtimeFields : {}; + if (requestAllFields) { + request.body._source = true; + } } else { request.body._source = true; } @@ -67,47 +64,50 @@ export function useEsDocSearch({ index, indexPatternId, indexPatternService, -}: DocProps): [ElasticRequestState, ElasticSearchHit | null, IndexPattern | null] { + requestSource, +}: DocProps): [ElasticRequestState, ElasticSearchHit | null, IndexPattern | null, () => void] { const [indexPattern, setIndexPattern] = useState(null); const [status, setStatus] = useState(ElasticRequestState.Loading); const [hit, setHit] = useState(null); const { data, uiSettings } = useMemo(() => getServices(), []); const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); - useEffect(() => { - async function requestData() { - try { - const indexPatternEntity = await indexPatternService.get(indexPatternId); - setIndexPattern(indexPatternEntity); + const requestData = useCallback(async () => { + try { + const indexPatternEntity = await indexPatternService.get(indexPatternId); + setIndexPattern(indexPatternEntity); - const { rawResponse } = await data.search - .search({ - params: { - index, - body: buildSearchBody(id, indexPatternEntity, useNewFieldsApi)?.body, - }, - }) - .toPromise(); + const { rawResponse } = await data.search + .search({ + params: { + index, + body: buildSearchBody(id, indexPatternEntity, useNewFieldsApi, requestSource)?.body, + }, + }) + .toPromise(); - const hits = rawResponse.hits; + const hits = rawResponse.hits; - if (hits?.hits?.[0]) { - setStatus(ElasticRequestState.Found); - setHit(hits.hits[0]); - } else { - setStatus(ElasticRequestState.NotFound); - } - } catch (err) { - if (err.savedObjectId) { - setStatus(ElasticRequestState.NotFoundIndexPattern); - } else if (err.status === 404) { - setStatus(ElasticRequestState.NotFound); - } else { - setStatus(ElasticRequestState.Error); - } + if (hits?.hits?.[0]) { + setStatus(ElasticRequestState.Found); + setHit(hits.hits[0]); + } else { + setStatus(ElasticRequestState.NotFound); + } + } catch (err) { + if (err.savedObjectId) { + setStatus(ElasticRequestState.NotFoundIndexPattern); + } else if (err.status === 404) { + setStatus(ElasticRequestState.NotFound); + } else { + setStatus(ElasticRequestState.Error); } } + }, [id, index, indexPatternId, indexPatternService, data.search, useNewFieldsApi, requestSource]); + + useEffect(() => { requestData(); - }, [id, index, indexPatternId, indexPatternService, data.search, useNewFieldsApi]); - return [status, hit, indexPattern]; + }, [requestData]); + + return [status, hit, indexPattern, requestData]; } diff --git a/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap b/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap index 8f07614813495..31dd6347218b5 100644 --- a/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap +++ b/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap @@ -1,21 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`returns the \`JsonCodeEditor\` component 1`] = ` - - - -
- - - -
-
- - - -
+ onEditorDidMount={[Function]} +/> `; diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss index 5521df5b363ac..335805ed28493 100644 --- a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss @@ -1,3 +1,3 @@ .dscJsonCodeEditor { - width: 100% + width: 100%; } diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx index b8427bb6bbdd2..f1ecd3ae3b70b 100644 --- a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx @@ -9,17 +9,8 @@ import './json_code_editor.scss'; import React, { useCallback } from 'react'; -import { i18n } from '@kbn/i18n'; -import { monaco, XJsonLang } from '@kbn/monaco'; -import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { CodeEditor } from '../../../../../kibana_react/public'; - -const codeEditorAriaLabel = i18n.translate('discover.json.codeEditorAriaLabel', { - defaultMessage: 'Read only JSON view of an elasticsearch document', -}); -const copyToClipboardLabel = i18n.translate('discover.json.copyToClipboardLabel', { - defaultMessage: 'Copy to clipboard', -}); +import { monaco } from '@kbn/monaco'; +import { JsonCodeEditorCommon } from './json_code_editor_common'; interface JsonCodeEditorProps { json: Record; @@ -47,45 +38,11 @@ export const JsonCodeEditor = ({ json, width, hasLineNumbers }: JsonCodeEditorPr }, []); return ( - - - -
- - {(copy) => ( - - {copyToClipboardLabel} - - )} - -
-
- - {}} - editorDidMount={setEditorCalculatedHeight} - aria-label={codeEditorAriaLabel} - options={{ - automaticLayout: true, - fontSize: 12, - lineNumbers: hasLineNumbers ? 'on' : 'off', - minimap: { - enabled: false, - }, - overviewRulerBorder: false, - readOnly: true, - scrollbar: { - alwaysConsumeMouseWheel: false, - }, - scrollBeyondLastLine: false, - wordWrap: 'on', - wrappingIndent: 'indent', - }} - /> - -
+ ); }; diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor_common.tsx b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor_common.tsx new file mode 100644 index 0000000000000..e5ab8bf4d1a0d --- /dev/null +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor_common.tsx @@ -0,0 +1,86 @@ +/* + * 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 './json_code_editor.scss'; + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { monaco, XJsonLang } from '@kbn/monaco'; +import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { CodeEditor } from '../../../../../kibana_react/public'; + +const codeEditorAriaLabel = i18n.translate('discover.json.codeEditorAriaLabel', { + defaultMessage: 'Read only JSON view of an elasticsearch document', +}); +const copyToClipboardLabel = i18n.translate('discover.json.copyToClipboardLabel', { + defaultMessage: 'Copy to clipboard', +}); + +interface JsonCodeEditorCommonProps { + jsonValue: string; + onEditorDidMount: (editor: monaco.editor.IStandaloneCodeEditor) => void; + width?: string | number; + hasLineNumbers?: boolean; +} + +export const JsonCodeEditorCommon = ({ + jsonValue, + width, + hasLineNumbers, + onEditorDidMount, +}: JsonCodeEditorCommonProps) => { + if (jsonValue === '') { + return null; + } + return ( + + + +
+ + {(copy) => ( + + {copyToClipboardLabel} + + )} + +
+
+ + {}} + editorDidMount={onEditorDidMount} + aria-label={codeEditorAriaLabel} + options={{ + automaticLayout: true, + fontSize: 12, + lineNumbers: hasLineNumbers ? 'on' : 'off', + minimap: { + enabled: false, + }, + overviewRulerBorder: false, + readOnly: true, + scrollbar: { + alwaysConsumeMouseWheel: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + }} + /> + +
+ ); +}; + +export const JSONCodeEditorCommonMemoized = React.memo((props: JsonCodeEditorCommonProps) => { + return ; +}); diff --git a/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap new file mode 100644 index 0000000000000..f40dbbbae1f87 --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap @@ -0,0 +1,760 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Source Viewer component renders error state 1`] = ` + + + Could not fetch data at this time. Refresh the tab to try again. + + + Refresh + + + } + iconType="alert" + title={ +

+ An Error Occurred +

+ } + > +
+ + + + +
+ + + + +

+ An Error Occurred +

+
+ +
+ + +
+
+ Could not fetch data at this time. Refresh the tab to try again. + +
+ + + + + + +
+
+ + + +
+ + +`; + +exports[`Source Viewer component renders json code editor 1`] = ` + + + + +
+ +
+ +
+ +
+ + + + + + + + + +
+
+ + +
+ + + } + > + + + + + + +
+
+
+ + + + +`; + +exports[`Source Viewer component renders loading state 1`] = ` + +
+ + + + +
+ +
+ + Loading JSON + +
+
+
+
+
+
+`; diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.scss b/src/plugins/discover/public/application/components/source_viewer/source_viewer.scss new file mode 100644 index 0000000000000..224e84ca50b52 --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.scss @@ -0,0 +1,14 @@ +.sourceViewer__loading { + display: flex; + flex-direction: row; + justify-content: left; + flex: 1 0 100%; + text-align: center; + height: 100%; + width: 100%; + margin-top: $euiSizeS; +} + +.sourceViewer__loadingSpinner { + margin-right: $euiSizeS; +} diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx b/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx new file mode 100644 index 0000000000000..86433e5df6401 --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx @@ -0,0 +1,118 @@ +/* + * 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 React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { SourceViewer } from './source_viewer'; +import * as hooks from '../doc/use_es_doc_search'; +import * as useUiSettingHook from 'src/plugins/kibana_react/public/ui_settings/use_ui_setting'; +import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; +import { JsonCodeEditorCommon } from '../json_code_editor/json_code_editor_common'; + +jest.mock('../../../kibana_services', () => ({ + getServices: jest.fn(), +})); + +import { getServices, IndexPattern } from '../../../kibana_services'; + +const mockIndexPattern = { + getComputedFields: () => [], +} as never; +const getMock = jest.fn(() => Promise.resolve(mockIndexPattern)); +const mockIndexPatternService = ({ + get: getMock, +} as unknown) as IndexPattern; + +(getServices as jest.Mock).mockImplementation(() => ({ + uiSettings: { + get: (key: string) => { + if (key === 'discover:useNewFieldsApi') { + return true; + } + }, + }, + data: { + indexPatternService: mockIndexPatternService, + }, +})); +describe('Source Viewer component', () => { + test('renders loading state', () => { + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [0, null, null, () => {}]); + + const comp = mountWithIntl( + + ); + expect(comp).toMatchSnapshot(); + const loadingIndicator = comp.find(EuiLoadingSpinner); + expect(loadingIndicator).not.toBe(null); + }); + + test('renders error state', () => { + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [3, null, null, () => {}]); + + const comp = mountWithIntl( + + ); + expect(comp).toMatchSnapshot(); + const errorPrompt = comp.find(EuiEmptyPrompt); + expect(errorPrompt.length).toBe(1); + const refreshButton = comp.find(EuiButton); + expect(refreshButton.length).toBe(1); + }); + + test('renders json code editor', () => { + const mockHit = { + _index: 'logstash-2014.09.09', + _type: 'doc', + _id: 'id123', + _score: 1, + _source: { + message: 'Lorem ipsum dolor sit amet', + extension: 'html', + not_mapped: 'yes', + bytes: 100, + objectArray: [{ foo: true }], + relatedContent: { + test: 1, + }, + scripted: 123, + _underscore: 123, + }, + } as never; + jest + .spyOn(hooks, 'useEsDocSearch') + .mockImplementation(() => [2, mockHit, mockIndexPattern, () => {}]); + jest.spyOn(useUiSettingHook, 'useUiSetting').mockImplementation(() => { + return false; + }); + const comp = mountWithIntl( + + ); + expect(comp).toMatchSnapshot(); + const jsonCodeEditor = comp.find(JsonCodeEditorCommon); + expect(jsonCodeEditor).not.toBe(null); + }); +}); diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx b/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx new file mode 100644 index 0000000000000..94a12c04613a9 --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx @@ -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 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 './source_viewer.scss'; + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { monaco } from '@kbn/monaco'; +import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useEsDocSearch } from '../doc/use_es_doc_search'; +import { JSONCodeEditorCommonMemoized } from '../json_code_editor/json_code_editor_common'; +import { ElasticRequestState } from '../doc/elastic_request_state'; +import { getServices } from '../../../../public/kibana_services'; +import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; + +interface SourceViewerProps { + id: string; + index: string; + indexPatternId: string; + hasLineNumbers: boolean; + width?: number; +} + +export const SourceViewer = ({ + id, + index, + indexPatternId, + width, + hasLineNumbers, +}: SourceViewerProps) => { + const [editor, setEditor] = useState(); + const [jsonValue, setJsonValue] = useState(''); + const indexPatternService = getServices().data.indexPatterns; + const useNewFieldsApi = !getServices().uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); + const [reqState, hit, , requestData] = useEsDocSearch({ + id, + index, + indexPatternId, + indexPatternService, + requestSource: useNewFieldsApi, + }); + + useEffect(() => { + if (reqState === ElasticRequestState.Found && hit) { + setJsonValue(JSON.stringify(hit, undefined, 2)); + } + }, [reqState, hit]); + + // setting editor height based on lines height and count to stretch and fit its content + useEffect(() => { + if (!editor) { + return; + } + const editorElement = editor.getDomNode(); + + if (!editorElement) { + return; + } + + const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); + const lineCount = editor.getModel()?.getLineCount() || 1; + const height = editor.getTopForLineNumber(lineCount + 1) + lineHeight; + if (!jsonValue || jsonValue === '') { + editorElement.style.height = '0px'; + } else { + editorElement.style.height = `${height}px`; + } + editor.layout(); + }, [editor, jsonValue]); + + const loadingState = ( +
+ + + + +
+ ); + + const errorMessageTitle = ( +

+ {i18n.translate('discover.sourceViewer.errorMessageTitle', { + defaultMessage: 'An Error Occurred', + })} +

+ ); + const errorMessage = ( +
+ {i18n.translate('discover.sourceViewer.errorMessage', { + defaultMessage: 'Could not fetch data at this time. Refresh the tab to try again.', + })} + + + {i18n.translate('discover.sourceViewer.refresh', { + defaultMessage: 'Refresh', + })} + +
+ ); + const errorState = ( + + ); + + if ( + reqState === ElasticRequestState.Error || + reqState === ElasticRequestState.NotFound || + reqState === ElasticRequestState.NotFoundIndexPattern + ) { + return errorState; + } + + if (reqState === ElasticRequestState.Loading || jsonValue === '') { + return loadingState; + } + + return ( + setEditor(editorNode)} + /> + ); +}; diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 139b23d28a1d4..7b4e7bb67c00e 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -37,7 +37,7 @@ import { UrlGeneratorState } from '../../share/public'; import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; import { DocViewTable } from './application/components/table/table'; -import { JsonCodeEditor } from './application/components/json_code_editor/json_code_editor'; + import { setDocViewsRegistry, setUrlTracker, @@ -63,6 +63,7 @@ import { SearchEmbeddableFactory } from './application/embeddable'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { replaceUrlHashQuery } from '../../kibana_utils/public/'; import { IndexPatternFieldEditorStart } from '../../../plugins/index_pattern_field_editor/public'; +import { SourceViewer } from './application/components/source_viewer/source_viewer'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -178,7 +179,6 @@ export class DiscoverPlugin }) ); } - this.docViewsRegistry = new DocViewsRegistry(); setDocViewsRegistry(this.docViewsRegistry); this.docViewsRegistry.addDocView({ @@ -193,8 +193,14 @@ export class DiscoverPlugin defaultMessage: 'JSON', }), order: 20, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - component: ({ hit }) => , + component: ({ hit, indexPattern }) => ( + + ), }); const { @@ -273,6 +279,7 @@ export class DiscoverPlugin // make sure the index pattern list is up to date await dataStart.indexPatterns.clearCache(); + const { renderApp } = await import('./application/application'); params.element.classList.add('dscAppWrapper'); const unmount = await renderApp(innerAngularName, params.element); diff --git a/src/plugins/expressions/common/expression_functions/specs/math_column.ts b/src/plugins/expressions/common/expression_functions/specs/math_column.ts index 0ff8faf3ce55a..633d912c29502 100644 --- a/src/plugins/expressions/common/expression_functions/specs/math_column.ts +++ b/src/plugins/expressions/common/expression_functions/specs/math_column.ts @@ -69,25 +69,40 @@ export const mathColumn: ExpressionFunctionDefinition< return id === args.id; }); if (existingColumnIndex > -1) { - throw new Error('ID must be unique'); + throw new Error( + i18n.translate('expressions.functions.mathColumn.uniqueIdError', { + defaultMessage: 'ID must be unique', + }) + ); } const newRows = input.rows.map((row) => { - return { - ...row, - [args.id]: math.fn( - { - type: 'datatable', - columns: input.columns, - rows: [row], - }, - { - expression: args.expression, - onError: args.onError, - }, - context - ), - }; + const result = math.fn( + { + type: 'datatable', + columns: input.columns, + rows: [row], + }, + { + expression: args.expression, + onError: args.onError, + }, + context + ); + + if (Array.isArray(result)) { + if (result.length === 1) { + return { ...row, [args.id]: result[0] }; + } + throw new Error( + i18n.translate('expressions.functions.mathColumn.arrayValueError', { + defaultMessage: 'Cannot perform math on array values at {name}', + values: { name: args.name }, + }) + ); + } + + return { ...row, [args.id]: result }; }); const type = newRows.length ? getType(newRows[0][args.id]) : 'null'; const newColumn: DatatableColumn = { diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts index bc6699a2b689b..e0fb0a3a9f23d 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts @@ -34,6 +34,30 @@ describe('mathColumn', () => { }); }); + it('extracts a single array value, but not a multi-value array', () => { + const arrayTable = { + ...testTable, + rows: [ + { + name: 'product1', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: [605, 500], + quantity: [100], + in_stock: true, + }, + ], + }; + const args = { + id: 'output', + name: 'output', + expression: 'quantity', + }; + expect(fn(arrayTable, args).rows[0].output).toEqual(100); + expect(() => fn(arrayTable, { ...args, expression: 'price' })).toThrowError( + `Cannot perform math on array values` + ); + }); + it('handles onError', () => { const args = { id: 'output', diff --git a/src/plugins/expressions/common/expression_types/get_type.test.ts b/src/plugins/expressions/common/expression_types/get_type.test.ts index 6eca54d2aea44..b1a9cb703182f 100644 --- a/src/plugins/expressions/common/expression_types/get_type.test.ts +++ b/src/plugins/expressions/common/expression_types/get_type.test.ts @@ -30,6 +30,7 @@ describe('getType()', () => { }); test('throws if object has no .type property', () => { + expect(() => getType([])).toThrow(); expect(() => getType({})).toThrow(); expect(() => getType({ _type: 'foo' })).toThrow(); expect(() => getType({ tipe: 'foo' })).toThrow(); diff --git a/src/plugins/expressions/common/expression_types/get_type.ts b/src/plugins/expressions/common/expression_types/get_type.ts index e29a610b3ed90..052508df41329 100644 --- a/src/plugins/expressions/common/expression_types/get_type.ts +++ b/src/plugins/expressions/common/expression_types/get_type.ts @@ -8,6 +8,9 @@ export function getType(node: any) { if (node == null) return 'null'; + if (Array.isArray(node)) { + throw new Error('Unexpected array value encountered.'); + } if (typeof node === 'object') { if (!node.type) throw new Error('Objects must have a type property'); return node.type; diff --git a/src/plugins/home/public/application/components/tutorial_directory.js b/src/plugins/home/public/application/components/tutorial_directory.js index 1fda865ebd847..d7e6c07d6dd18 100644 --- a/src/plugins/home/public/application/components/tutorial_directory.js +++ b/src/plugins/home/public/application/components/tutorial_directory.js @@ -9,27 +9,15 @@ import _ from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; +import { EuiFlexItem, EuiFlexGrid, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { Synopsis } from './synopsis'; import { SampleDataSetCards } from './sample_data_set_cards'; import { getServices } from '../kibana_services'; - -import { - EuiPage, - EuiTabs, - EuiTab, - EuiFlexItem, - EuiFlexGrid, - EuiFlexGroup, - EuiSpacer, - EuiTitle, - EuiPageBody, -} from '@elastic/eui'; - +import { KibanaPageTemplate } from '../../../../kibana_react/public'; import { getTutorials } from '../load_tutorials'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - const ALL_TAB_ID = 'all'; const SAMPLE_DATA_TAB_ID = 'sampleData'; @@ -184,17 +172,13 @@ class TutorialDirectoryUi extends React.Component { }); }; - renderTabs = () => { - return this.tabs.map((tab, index) => ( - this.onSelectedTabChanged(tab.id)} - isSelected={tab.id === this.state.selectedTabId} - key={index} - > - {tab.name} - - )); + getTabs = () => { + return this.tabs.map((tab) => ({ + label: tab.name, + onClick: () => this.onSelectedTabChanged(tab.id), + isSelected: tab.id === this.state.selectedTabId, + 'data-test-subj': `homeTab-${tab.id}`, + })); }; renderTabContent = () => { @@ -258,41 +242,31 @@ class TutorialDirectoryUi extends React.Component { ) : null; }; - renderHeader = () => { - const notices = this.renderNotices(); + render() { const headerLinks = this.renderHeaderLinks(); + const tabs = this.getTabs(); + const notices = this.renderNotices(); return ( - <> - - - -

- -

-
-
- {headerLinks ? {headerLinks} : null} -
- {notices} - - ); - }; - - render() { - return ( - - - {this.renderHeader()} - - {this.renderTabs()} - - {this.renderTabContent()} - - + + ), + tabs, + rightSideItems: headerLinks ? [headerLinks] : [], + }} + > + {notices && ( + <> + {notices} + + + )} + {this.renderTabContent()} + ); } } diff --git a/src/plugins/kibana_react/public/field_button/field_button.scss b/src/plugins/kibana_react/public/field_button/field_button.scss index 43f60e4503576..f71e097ab7138 100644 --- a/src/plugins/kibana_react/public/field_button/field_button.scss +++ b/src/plugins/kibana_react/public/field_button/field_button.scss @@ -38,6 +38,7 @@ padding: $euiSizeS; display: flex; align-items: flex-start; + line-height: normal; } .kbnFieldButton__fieldIcon { diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/constants.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/constants.ts new file mode 100644 index 0000000000000..1753c87c9d005 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/constants.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 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. + */ + +/** + * Roll daily indices every 24h + */ +export const ROLL_DAILY_INDICES_INTERVAL = 24 * 60 * 60 * 1000; + +/** + * Start rolling indices after 5 minutes up + */ +export const ROLL_INDICES_START = 5 * 60 * 1000; + +/** + * Reset the event loop delay historgram every 1 hour + */ +export const MONITOR_EVENT_LOOP_DELAYS_INTERVAL = 1 * 60 * 60 * 1000; + +/** + * Reset the event loop delay historgram every 24h + */ +export const MONITOR_EVENT_LOOP_DELAYS_RESET = 24 * 60 * 60 * 1000; + +/** + * Start monitoring the event loop delays after 1 minute + */ +export const MONITOR_EVENT_LOOP_DELAYS_START = 1 * 60 * 1000; + +/** + * Event loop monitoring sampling rate in milliseconds. + */ +export const MONITOR_EVENT_LOOP_DELAYS_RESOLUTION = 10; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.mocks.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.mocks.ts new file mode 100644 index 0000000000000..6b03d3cc5cbd1 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.mocks.ts @@ -0,0 +1,49 @@ +/* + * 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 moment from 'moment'; +import type { IntervalHistogram } from './event_loop_delays'; + +export const mockMonitorEnable = jest.fn(); +export const mockMonitorPercentile = jest.fn(); +export const mockMonitorReset = jest.fn(); +export const mockMonitorDisable = jest.fn(); +export const monitorEventLoopDelay = jest.fn().mockReturnValue({ + enable: mockMonitorEnable, + percentile: mockMonitorPercentile, + disable: mockMonitorDisable, + reset: mockMonitorReset, +}); + +jest.doMock('perf_hooks', () => ({ + monitorEventLoopDelay, +})); + +function createMockHistogram(overwrites: Partial = {}): IntervalHistogram { + const now = moment(); + + return { + min: 9093120, + max: 53247999, + mean: 11993238.600747818, + exceeds: 0, + stddev: 1168191.9357543814, + fromTimestamp: now.startOf('day').toISOString(), + lastUpdatedAt: now.toISOString(), + percentiles: { + '50': 12607487, + '75': 12615679, + '95': 12648447, + '99': 12713983, + }, + ...overwrites, + }; +} + +export const mocked = { + createHistogram: createMockHistogram, +}; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.test.ts new file mode 100644 index 0000000000000..d03236a9756b3 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.test.ts @@ -0,0 +1,135 @@ +/* + * 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 { Subject } from 'rxjs'; + +import { + mockMonitorEnable, + mockMonitorPercentile, + monitorEventLoopDelay, + mockMonitorReset, + mockMonitorDisable, +} from './event_loop_delays.mocks'; +import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import { startTrackingEventLoopDelaysUsage, EventLoopDelaysCollector } from './event_loop_delays'; + +describe('EventLoopDelaysCollector', () => { + jest.useFakeTimers('modern'); + const mockNow = jest.getRealSystemTime(); + jest.setSystemTime(mockNow); + + beforeEach(() => jest.clearAllMocks()); + afterAll(() => jest.useRealTimers()); + + test('#constructor enables monitoring', () => { + new EventLoopDelaysCollector(); + expect(monitorEventLoopDelay).toBeCalledWith({ resolution: 10 }); + expect(mockMonitorEnable).toBeCalledTimes(1); + }); + + test('#collect returns event loop delays histogram', () => { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + const histogramData = eventLoopDelaysCollector.collect(); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(1, 50); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(2, 75); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(3, 95); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(4, 99); + + expect(Object.keys(histogramData)).toMatchInlineSnapshot(` + Array [ + "min", + "max", + "mean", + "exceeds", + "stddev", + "fromTimestamp", + "lastUpdatedAt", + "percentiles", + ] + `); + }); + test('#reset resets histogram data', () => { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + eventLoopDelaysCollector.reset(); + expect(mockMonitorReset).toBeCalledTimes(1); + }); + test('#stop disables monitoring event loop delays', () => { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + eventLoopDelaysCollector.stop(); + expect(mockMonitorDisable).toBeCalledTimes(1); + }); +}); + +describe('startTrackingEventLoopDelaysUsage', () => { + const mockInternalRepository = savedObjectsRepositoryMock.create(); + const stopMonitoringEventLoop$ = new Subject(); + + beforeAll(() => jest.useFakeTimers('modern')); + beforeEach(() => jest.clearAllMocks()); + afterEach(() => stopMonitoringEventLoop$.next()); + + it('initializes EventLoopDelaysCollector and starts timer', () => { + const collectionStartDelay = 1000; + startTrackingEventLoopDelaysUsage( + mockInternalRepository, + stopMonitoringEventLoop$, + collectionStartDelay + ); + + expect(monitorEventLoopDelay).toBeCalledTimes(1); + expect(mockMonitorPercentile).toBeCalledTimes(0); + jest.advanceTimersByTime(collectionStartDelay); + expect(mockMonitorPercentile).toBeCalled(); + }); + + it('stores event loop delays every collectionInterval duration', () => { + const collectionStartDelay = 100; + const collectionInterval = 1000; + startTrackingEventLoopDelaysUsage( + mockInternalRepository, + stopMonitoringEventLoop$, + collectionStartDelay, + collectionInterval + ); + + expect(mockInternalRepository.create).toBeCalledTimes(0); + jest.advanceTimersByTime(collectionStartDelay); + expect(mockInternalRepository.create).toBeCalledTimes(1); + jest.advanceTimersByTime(collectionInterval); + expect(mockInternalRepository.create).toBeCalledTimes(2); + jest.advanceTimersByTime(collectionInterval); + expect(mockInternalRepository.create).toBeCalledTimes(3); + }); + + it('resets histogram every histogramReset duration', () => { + const collectionStartDelay = 0; + const collectionInterval = 1000; + const histogramReset = 5000; + startTrackingEventLoopDelaysUsage( + mockInternalRepository, + stopMonitoringEventLoop$, + collectionStartDelay, + collectionInterval, + histogramReset + ); + + expect(mockMonitorReset).toBeCalledTimes(0); + jest.advanceTimersByTime(collectionInterval * 5); + expect(mockMonitorReset).toBeCalledTimes(1); + jest.advanceTimersByTime(collectionInterval * 5); + expect(mockMonitorReset).toBeCalledTimes(2); + }); + + it('stops monitoring event loop delays once stopMonitoringEventLoop$.next is called', () => { + startTrackingEventLoopDelaysUsage(mockInternalRepository, stopMonitoringEventLoop$); + + expect(mockMonitorDisable).toBeCalledTimes(0); + stopMonitoringEventLoop$.next(); + expect(mockMonitorDisable).toBeCalledTimes(1); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.ts new file mode 100644 index 0000000000000..655cba580fc5d --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.ts @@ -0,0 +1,109 @@ +/* + * 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 { EventLoopDelayMonitor } from 'perf_hooks'; +import { monitorEventLoopDelay } from 'perf_hooks'; +import { takeUntil, finalize, map } from 'rxjs/operators'; +import { Observable, timer } from 'rxjs'; +import type { ISavedObjectsRepository } from 'kibana/server'; +import { + MONITOR_EVENT_LOOP_DELAYS_START, + MONITOR_EVENT_LOOP_DELAYS_INTERVAL, + MONITOR_EVENT_LOOP_DELAYS_RESET, + MONITOR_EVENT_LOOP_DELAYS_RESOLUTION, +} from './constants'; +import { storeHistogram } from './saved_objects'; + +export interface IntervalHistogram { + fromTimestamp: string; + lastUpdatedAt: string; + min: number; + max: number; + mean: number; + exceeds: number; + stddev: number; + percentiles: { + 50: number; + 75: number; + 95: number; + 99: number; + }; +} + +export class EventLoopDelaysCollector { + private readonly loopMonitor: EventLoopDelayMonitor; + private fromTimestamp: Date; + + constructor() { + const monitor = monitorEventLoopDelay({ + resolution: MONITOR_EVENT_LOOP_DELAYS_RESOLUTION, + }); + monitor.enable(); + this.fromTimestamp = new Date(); + this.loopMonitor = monitor; + } + + public collect(): IntervalHistogram { + const { min, max, mean, exceeds, stddev } = this.loopMonitor; + + return { + min, + max, + mean, + exceeds, + stddev, + fromTimestamp: this.fromTimestamp.toISOString(), + lastUpdatedAt: new Date().toISOString(), + percentiles: { + 50: this.loopMonitor.percentile(50), + 75: this.loopMonitor.percentile(75), + 95: this.loopMonitor.percentile(95), + 99: this.loopMonitor.percentile(99), + }, + }; + } + + public reset() { + this.loopMonitor.reset(); + this.fromTimestamp = new Date(); + } + + public stop() { + this.loopMonitor.disable(); + } +} + +/** + * The monitoring of the event loop starts immediately. + * The first collection of the histogram happens after 1 minute. + * The daily histogram data is updated every 1 hour. + */ +export function startTrackingEventLoopDelaysUsage( + internalRepository: ISavedObjectsRepository, + stopMonitoringEventLoop$: Observable, + collectionStartDelay = MONITOR_EVENT_LOOP_DELAYS_START, + collectionInterval = MONITOR_EVENT_LOOP_DELAYS_INTERVAL, + histogramReset = MONITOR_EVENT_LOOP_DELAYS_RESET +) { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + + const resetOnCount = Math.ceil(histogramReset / collectionInterval); + timer(collectionStartDelay, collectionInterval) + .pipe( + map((i) => (i + 1) % resetOnCount === 0), + takeUntil(stopMonitoringEventLoop$), + finalize(() => eventLoopDelaysCollector.stop()) + ) + .subscribe(async (shouldReset) => { + const histogram = eventLoopDelaysCollector.collect(); + if (shouldReset) { + eventLoopDelaysCollector.reset(); + } + await storeHistogram(histogram, internalRepository); + }); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.test.ts new file mode 100644 index 0000000000000..06c51f6afa3a8 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.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 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 { + Collector, + createUsageCollectionSetupMock, + createCollectorFetchContextMock, +} from '../../../../usage_collection/server/mocks'; +import { registerEventLoopDelaysCollector } from './event_loop_delays_usage_collector'; +import { loggingSystemMock, savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import type { SavedObjectsFindResponse } from '../../../../../core/server'; + +const logger = loggingSystemMock.createLogger(); + +describe('registerEventLoopDelaysCollector', () => { + let collector: Collector; + const mockRegisterType = jest.fn(); + const mockInternalRepository = savedObjectsRepositoryMock.create(); + const mockGetSavedObjectsClient = () => mockInternalRepository; + + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = new Collector(logger, config); + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + + const collectorFetchContext = createCollectorFetchContextMock(); + + beforeAll(() => { + registerEventLoopDelaysCollector( + logger, + usageCollectionMock, + mockRegisterType, + mockGetSavedObjectsClient + ); + }); + + it('registers event_loop_delays collector', () => { + expect(collector).not.toBeUndefined(); + expect(collector.type).toBe('event_loop_delays'); + }); + + it('registers savedObjectType "event_loop_delays_daily"', () => { + expect(mockRegisterType).toBeCalledTimes(1); + expect(mockRegisterType).toBeCalledWith( + expect.objectContaining({ + name: 'event_loop_delays_daily', + }) + ); + }); + + it('returns objects from event_loop_delays_daily from fetch function', async () => { + const mockFind = jest.fn().mockResolvedValue(({ + saved_objects: [{ attributes: { test: 1 } }], + } as unknown) as SavedObjectsFindResponse); + mockInternalRepository.find = mockFind; + const fetchResult = await collector.fetch(collectorFetchContext); + + expect(fetchResult).toMatchInlineSnapshot(` + Object { + "daily": Array [ + Object { + "test": 1, + }, + ], + } + `); + expect(mockFind).toBeCalledTimes(1); + expect(mockFind.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "sortField": "updated_at", + "sortOrder": "desc", + "type": "event_loop_delays_daily", + }, + ] + `); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.ts new file mode 100644 index 0000000000000..774e021d7a549 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.ts @@ -0,0 +1,53 @@ +/* + * 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 { timer } from 'rxjs'; +import { SavedObjectsServiceSetup, ISavedObjectsRepository, Logger } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { rollDailyData } from './rollups'; +import { registerSavedObjectTypes, EventLoopDelaysDaily } from './saved_objects'; +import { eventLoopDelaysUsageSchema, EventLoopDelaysUsageReport } from './schema'; +import { SAVED_OBJECTS_DAILY_TYPE } from './saved_objects'; +import { ROLL_DAILY_INDICES_INTERVAL, ROLL_INDICES_START } from './constants'; + +export function registerEventLoopDelaysCollector( + logger: Logger, + usageCollection: UsageCollectionSetup, + registerType: SavedObjectsServiceSetup['registerType'], + getSavedObjectsClient: () => ISavedObjectsRepository | undefined +) { + registerSavedObjectTypes(registerType); + + timer(ROLL_INDICES_START, ROLL_DAILY_INDICES_INTERVAL).subscribe(() => + rollDailyData(logger, getSavedObjectsClient()) + ); + + const collector = usageCollection.makeUsageCollector({ + type: 'event_loop_delays', + isReady: () => typeof getSavedObjectsClient() !== 'undefined', + schema: eventLoopDelaysUsageSchema, + fetch: async () => { + const internalRepository = getSavedObjectsClient(); + if (!internalRepository) { + return { daily: [] }; + } + + const { saved_objects: savedObjects } = await internalRepository.find({ + type: SAVED_OBJECTS_DAILY_TYPE, + sortField: 'updated_at', + sortOrder: 'desc', + }); + + return { + daily: savedObjects.map((savedObject) => savedObject.attributes), + }; + }, + }); + + usageCollection.registerCollector(collector); +} diff --git a/packages/kbn-interpreter/src/common/index.d.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/index.ts similarity index 61% rename from packages/kbn-interpreter/src/common/index.d.ts rename to src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/index.ts index 6f54d07590973..693b173c2759e 100644 --- a/packages/kbn-interpreter/src/common/index.d.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/index.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -export { Registry } from './lib/registry'; - -export { fromExpression, toExpression, Ast, ExpressionFunctionAST } from './lib/ast'; -export { getType } from './lib/get_type'; +export { startTrackingEventLoopDelaysUsage } from './event_loop_delays'; +export { registerEventLoopDelaysCollector } from './event_loop_delays_usage_collector'; +export { SAVED_OBJECTS_DAILY_TYPE } from './saved_objects'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.test.ts new file mode 100644 index 0000000000000..cb59d6a44b07e --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { rollDailyData } from './daily'; +import { loggingSystemMock, savedObjectsRepositoryMock } from '../../../../../../core/server/mocks'; +import type { SavedObjectsFindResponse } from '../../../../../../core/server'; + +describe('rollDailyData', () => { + const logger = loggingSystemMock.createLogger(); + const mockSavedObjectsClient = savedObjectsRepositoryMock.create(); + + beforeEach(() => jest.clearAllMocks()); + + it('returns false if no savedObjectsClient', async () => { + await rollDailyData(logger, undefined); + expect(mockSavedObjectsClient.find).toBeCalledTimes(0); + }); + + it('calls delete on documents older than 3 days', async () => { + mockSavedObjectsClient.find.mockResolvedValueOnce({ + saved_objects: [{ id: 'test_id_1' }, { id: 'test_id_2' }], + } as SavedObjectsFindResponse); + + await rollDailyData(logger, mockSavedObjectsClient); + + expect(mockSavedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsClient.delete).toBeCalledTimes(2); + expect(mockSavedObjectsClient.delete).toHaveBeenNthCalledWith( + 1, + 'event_loop_delays_daily', + 'test_id_1' + ); + expect(mockSavedObjectsClient.delete).toHaveBeenNthCalledWith( + 2, + 'event_loop_delays_daily', + 'test_id_2' + ); + }); + + it('calls logger.debug on repository find error', async () => { + const mockError = new Error('find error'); + mockSavedObjectsClient.find.mockRejectedValueOnce(mockError); + + await rollDailyData(logger, mockSavedObjectsClient); + expect(logger.debug).toBeCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + 'Failed to rollup transactional to daily entries' + ); + expect(logger.debug).toHaveBeenNthCalledWith(2, mockError); + }); + + it('settles all deletes before logging failures', async () => { + const mockError1 = new Error('delete error 1'); + const mockError2 = new Error('delete error 2'); + mockSavedObjectsClient.find.mockResolvedValueOnce({ + saved_objects: [{ id: 'test_id_1' }, { id: 'test_id_2' }, { id: 'test_id_3' }], + } as SavedObjectsFindResponse); + + mockSavedObjectsClient.delete.mockRejectedValueOnce(mockError1); + mockSavedObjectsClient.delete.mockResolvedValueOnce(true); + mockSavedObjectsClient.delete.mockRejectedValueOnce(mockError2); + + await rollDailyData(logger, mockSavedObjectsClient); + expect(mockSavedObjectsClient.delete).toBeCalledTimes(3); + expect(logger.debug).toBeCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + 'Failed to rollup transactional to daily entries' + ); + expect(logger.debug).toHaveBeenNthCalledWith(2, [ + { reason: mockError1, status: 'rejected' }, + { reason: mockError2, status: 'rejected' }, + ]); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.ts new file mode 100644 index 0000000000000..29072335d272b --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.ts @@ -0,0 +1,35 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import { ISavedObjectsRepository } from '../../../../../../core/server'; +import { deleteHistogramSavedObjects } from '../saved_objects'; + +/** + * daily rollup function. Deletes histogram saved objects older than 3 days + * @param logger + * @param savedObjectsClient + */ +export async function rollDailyData( + logger: Logger, + savedObjectsClient?: ISavedObjectsRepository +): Promise { + if (!savedObjectsClient) { + return; + } + try { + const settledDeletes = await deleteHistogramSavedObjects(savedObjectsClient); + const failedDeletes = settledDeletes.filter(({ status }) => status !== 'fulfilled'); + if (failedDeletes.length) { + throw failedDeletes; + } + } catch (err) { + logger.debug(`Failed to rollup transactional to daily entries`); + logger.debug(err); + } +} diff --git a/packages/kbn-interpreter/scripts/build.js b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/index.ts similarity index 89% rename from packages/kbn-interpreter/scripts/build.js rename to src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/index.ts index 21b7f86c6bc34..4523069a820e7 100644 --- a/packages/kbn-interpreter/scripts/build.js +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -require('../tasks/build/cli'); +export { rollDailyData } from './daily'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts new file mode 100644 index 0000000000000..8c227f260da6e --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts @@ -0,0 +1,94 @@ +/* + * 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 { Logger, ISavedObjectsRepository } from '../../../../../../../core/server'; +import { + createTestServers, + TestElasticsearchUtils, + TestKibanaUtils, + createRootWithCorePlugins, +} from '../../../../../../../core/test_helpers/kbn_server'; +import { rollDailyData } from '../daily'; +import { mocked } from '../../event_loop_delays.mocks'; + +import { + SAVED_OBJECTS_DAILY_TYPE, + serializeSavedObjectId, + EventLoopDelaysDaily, +} from '../../saved_objects'; +import moment from 'moment'; + +const { startES } = createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), +}); + +function createRawObject(date: moment.MomentInput) { + const pid = Math.round(Math.random() * 10000); + return { + type: SAVED_OBJECTS_DAILY_TYPE, + id: serializeSavedObjectId({ pid, date }), + attributes: { + ...mocked.createHistogram({ + fromTimestamp: moment(date).startOf('day').toISOString(), + lastUpdatedAt: moment(date).toISOString(), + }), + processId: pid, + }, + }; +} + +const rawEventLoopDelaysDaily = [ + createRawObject(moment.now()), + createRawObject(moment.now()), + createRawObject(moment().subtract(1, 'days')), + createRawObject(moment().subtract(3, 'days')), +]; + +const outdatedRawEventLoopDelaysDaily = [ + createRawObject(moment().subtract(5, 'days')), + createRawObject(moment().subtract(7, 'days')), +]; + +describe('daily rollups integration test', () => { + let esServer: TestElasticsearchUtils; + let root: TestKibanaUtils['root']; + let internalRepository: ISavedObjectsRepository; + let logger: Logger; + + beforeAll(async () => { + esServer = await startES(); + root = createRootWithCorePlugins(); + + await root.setup(); + const start = await root.start(); + logger = root.logger.get('test dailt rollups'); + internalRepository = start.savedObjects.createInternalRepository([SAVED_OBJECTS_DAILY_TYPE]); + + await internalRepository.bulkCreate( + [...rawEventLoopDelaysDaily, ...outdatedRawEventLoopDelaysDaily], + { refresh: true } + ); + }); + + afterAll(async () => { + await esServer.stop(); + await root.shutdown(); + }); + + it('deletes documents older that 3 days from the saved objects repository', async () => { + await rollDailyData(logger, internalRepository); + const { + total, + saved_objects: savedObjects, + } = await internalRepository.find({ type: SAVED_OBJECTS_DAILY_TYPE }); + expect(total).toBe(rawEventLoopDelaysDaily.length); + expect(savedObjects.map(({ id, type, attributes }) => ({ id, type, attributes }))).toEqual( + rawEventLoopDelaysDaily + ); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.test.ts new file mode 100644 index 0000000000000..022040615bd45 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.test.ts @@ -0,0 +1,122 @@ +/* + * 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 { + storeHistogram, + serializeSavedObjectId, + deleteHistogramSavedObjects, +} from './saved_objects'; +import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import type { SavedObjectsFindResponse } from '../../../../../core/server/'; +import { mocked } from './event_loop_delays.mocks'; + +describe('serializeSavedObjectId', () => { + it('returns serialized id', () => { + const id = serializeSavedObjectId({ date: 1623233091278, pid: 123 }); + expect(id).toBe('123::09062021'); + }); +}); + +describe('storeHistogram', () => { + const mockHistogram = mocked.createHistogram(); + const mockInternalRepository = savedObjectsRepositoryMock.create(); + + jest.useFakeTimers('modern'); + const mockNow = jest.getRealSystemTime(); + jest.setSystemTime(mockNow); + + beforeEach(() => jest.clearAllMocks()); + afterAll(() => jest.useRealTimers()); + + it('stores histogram data in a savedObject', async () => { + await storeHistogram(mockHistogram, mockInternalRepository); + const pid = process.pid; + const id = serializeSavedObjectId({ date: mockNow, pid }); + + expect(mockInternalRepository.create).toBeCalledWith( + 'event_loop_delays_daily', + { ...mockHistogram, processId: pid }, + { id, overwrite: true } + ); + }); +}); + +describe('deleteHistogramSavedObjects', () => { + const mockInternalRepository = savedObjectsRepositoryMock.create(); + + beforeEach(() => { + jest.clearAllMocks(); + mockInternalRepository.find.mockResolvedValue({ + saved_objects: [{ id: 'test_obj_1' }, { id: 'test_obj_1' }], + } as SavedObjectsFindResponse); + }); + + it('builds filter query based on time range passed in days', async () => { + await deleteHistogramSavedObjects(mockInternalRepository); + await deleteHistogramSavedObjects(mockInternalRepository, 20); + expect(mockInternalRepository.find.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "filter": "event_loop_delays_daily.attributes.lastUpdatedAt < \\"now-3d/d\\"", + "type": "event_loop_delays_daily", + }, + ], + Array [ + Object { + "filter": "event_loop_delays_daily.attributes.lastUpdatedAt < \\"now-20d/d\\"", + "type": "event_loop_delays_daily", + }, + ], + ] + `); + }); + + it('loops over saved objects and deletes them', async () => { + mockInternalRepository.delete.mockImplementation(async (type, id) => { + return id; + }); + + const results = await deleteHistogramSavedObjects(mockInternalRepository); + expect(results).toMatchInlineSnapshot(` + Array [ + Object { + "status": "fulfilled", + "value": "test_obj_1", + }, + Object { + "status": "fulfilled", + "value": "test_obj_1", + }, + ] + `); + }); + + it('settles all promises even if some of the deletes fail.', async () => { + mockInternalRepository.delete.mockImplementationOnce(async (type, id) => { + throw new Error('Intentional failure'); + }); + mockInternalRepository.delete.mockImplementationOnce(async (type, id) => { + return id; + }); + + const results = await deleteHistogramSavedObjects(mockInternalRepository); + expect(results).toMatchInlineSnapshot(` + Array [ + Object { + "reason": [Error: Intentional failure], + "status": "rejected", + }, + Object { + "status": "fulfilled", + "value": "test_obj_1", + }, + ] + `); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.ts new file mode 100644 index 0000000000000..610a6697da364 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { + SavedObjectAttributes, + SavedObjectsServiceSetup, + ISavedObjectsRepository, +} from 'kibana/server'; +import moment from 'moment'; +import type { IntervalHistogram } from './event_loop_delays'; + +export const SAVED_OBJECTS_DAILY_TYPE = 'event_loop_delays_daily'; + +export interface EventLoopDelaysDaily extends SavedObjectAttributes, IntervalHistogram { + processId: number; +} + +export function registerSavedObjectTypes(registerType: SavedObjectsServiceSetup['registerType']) { + registerType({ + name: SAVED_OBJECTS_DAILY_TYPE, + hidden: true, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: { + // This type requires `lastUpdatedAt` to be indexed so we can use it when rolling up totals (lastUpdatedAt < now-90d) + lastUpdatedAt: { type: 'date' }, + }, + }, + }); +} + +export function serializeSavedObjectId({ date, pid }: { date: moment.MomentInput; pid: number }) { + const formattedDate = moment(date).format('DDMMYYYY'); + + return `${pid}::${formattedDate}`; +} + +export async function deleteHistogramSavedObjects( + internalRepository: ISavedObjectsRepository, + daysTimeRange = 3 +) { + const { saved_objects: savedObjects } = await internalRepository.find({ + type: SAVED_OBJECTS_DAILY_TYPE, + filter: `${SAVED_OBJECTS_DAILY_TYPE}.attributes.lastUpdatedAt < "now-${daysTimeRange}d/d"`, + }); + + return await Promise.allSettled( + savedObjects.map(async (savedObject) => { + return await internalRepository.delete(SAVED_OBJECTS_DAILY_TYPE, savedObject.id); + }) + ); +} + +export async function storeHistogram( + histogram: IntervalHistogram, + internalRepository: ISavedObjectsRepository +) { + const pid = process.pid; + const id = serializeSavedObjectId({ date: histogram.lastUpdatedAt, pid }); + + return await internalRepository.create( + SAVED_OBJECTS_DAILY_TYPE, + { ...histogram, processId: pid }, + { id, overwrite: true } + ); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/schema.ts new file mode 100644 index 0000000000000..319e8c77438b8 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/schema.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { MakeSchemaFrom } from 'src/plugins/usage_collection/server'; + +export interface EventLoopDelaysUsageReport { + daily: Array<{ + processId: number; + lastUpdatedAt: string; + fromTimestamp: string; + min: number; + max: number; + mean: number; + exceeds: number; + stddev: number; + percentiles: { + '50': number; + '75': number; + '95': number; + '99': number; + }; + }>; +} + +export const eventLoopDelaysUsageSchema: MakeSchemaFrom = { + daily: { + type: 'array', + items: { + processId: { + type: 'long', + _meta: { + description: 'The process id of the monitored kibana instance.', + }, + }, + fromTimestamp: { + type: 'date', + _meta: { + description: 'Timestamp at which the histogram started monitoring.', + }, + }, + lastUpdatedAt: { + type: 'date', + _meta: { + description: 'Latest timestamp this histogram object was updated this day.', + }, + }, + min: { + type: 'long', + _meta: { + description: 'The minimum recorded event loop delay.', + }, + }, + max: { + type: 'long', + _meta: { + description: 'The maximum recorded event loop delay.', + }, + }, + mean: { + type: 'long', + _meta: { + description: 'The mean of the recorded event loop delays.', + }, + }, + exceeds: { + type: 'long', + _meta: { + description: + 'The number of times the event loop delay exceeded the maximum 1 hour eventloop delay threshold.', + }, + }, + stddev: { + type: 'long', + _meta: { + description: 'The standard deviation of the recorded event loop delays.', + }, + }, + percentiles: { + '50': { + type: 'long', + _meta: { + description: 'The 50th accumulated percentile distribution', + }, + }, + '75': { + type: 'long', + _meta: { + description: 'The 75th accumulated percentile distribution', + }, + }, + '95': { + type: 'long', + _meta: { + description: 'The 95th accumulated percentile distribution', + }, + }, + '99': { + type: 'long', + _meta: { + description: 'The 99th accumulated percentile distribution', + }, + }, + }, + }, + }, +}; diff --git a/src/plugins/kibana_usage_collection/server/collectors/index.ts b/src/plugins/kibana_usage_collection/server/collectors/index.ts index 761989938e56d..e4ed24611bfa8 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/index.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/index.ts @@ -28,3 +28,4 @@ export { registerUsageCountersRollups, registerUsageCountersUsageCollector, } from './usage_counters'; +export { registerEventLoopDelaysCollector } from './event_loop_delays'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/saved_objects_counts/get_saved_object_counts.ts b/src/plugins/kibana_usage_collection/server/collectors/saved_objects_counts/get_saved_object_counts.ts index 9927b27da6c8f..eeaeed67e753f 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/saved_objects_counts/get_saved_object_counts.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/saved_objects_counts/get_saved_object_counts.ts @@ -26,6 +26,6 @@ export async function getSavedObjectsCounts( }, }; const { body } = await esClient.search(savedObjectCountSearchParams); - // @ts-expect-error @elastic/elasticsearch Aggregate does not include `buckets` + // @ts-expect-error declare type for aggregations explicitly return body.aggregations?.types?.buckets || []; } diff --git a/src/plugins/kibana_usage_collection/server/plugin.test.ts b/src/plugins/kibana_usage_collection/server/plugin.test.ts index 2100b9bbb918b..1584366a42dc1 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.test.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.test.ts @@ -16,7 +16,6 @@ import { createUsageCollectionSetupMock, } from '../../usage_collection/server/mocks'; import { cloudDetailsMock } from './mocks'; - import { plugin } from './'; describe('kibana_usage_collection', () => { @@ -105,6 +104,10 @@ describe('kibana_usage_collection', () => { "isReady": true, "type": "localization", }, + Object { + "isReady": false, + "type": "event_loop_delays", + }, ] `); }); diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index da6445ce957d8..4ec717c48610e 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -22,6 +22,10 @@ import type { CoreUsageDataStart, } from 'src/core/server'; import { SavedObjectsClient } from '../../../core/server'; +import { + startTrackingEventLoopDelaysUsage, + SAVED_OBJECTS_DAILY_TYPE, +} from './collectors/event_loop_delays'; import { registerApplicationUsageCollector, registerKibanaUsageCollector, @@ -39,6 +43,7 @@ import { registerUsageCountersRollups, registerUsageCountersUsageCollector, registerSavedObjectsCountUsageCollector, + registerEventLoopDelaysCollector, } from './collectors'; interface KibanaUsageCollectionPluginsDepsSetup { @@ -54,46 +59,46 @@ export class KibanaUsageCollectionPlugin implements Plugin { private uiSettingsClient?: IUiSettingsClient; private metric$: Subject; private coreUsageData?: CoreUsageDataStart; - private stopUsingUiCounterIndicies$: Subject; + private pluginStop$: Subject; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); this.legacyConfig$ = initializerContext.config.legacy.globalConfig$; this.metric$ = new Subject(); - this.stopUsingUiCounterIndicies$ = new Subject(); + this.pluginStop$ = new Subject(); } public setup(coreSetup: CoreSetup, { usageCollection }: KibanaUsageCollectionPluginsDepsSetup) { usageCollection.createUsageCounter('uiCounters'); - this.registerUsageCollectors( usageCollection, coreSetup, this.metric$, - this.stopUsingUiCounterIndicies$, + this.pluginStop$, coreSetup.savedObjects.registerType.bind(coreSetup.savedObjects) ); } public start(core: CoreStart) { const { savedObjects, uiSettings } = core; - this.savedObjectsClient = savedObjects.createInternalRepository(); + this.savedObjectsClient = savedObjects.createInternalRepository([SAVED_OBJECTS_DAILY_TYPE]); const savedObjectsClient = new SavedObjectsClient(this.savedObjectsClient); this.uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient); core.metrics.getOpsMetrics$().subscribe(this.metric$); this.coreUsageData = core.coreUsageData; + startTrackingEventLoopDelaysUsage(this.savedObjectsClient, this.pluginStop$.asObservable()); } public stop() { this.metric$.complete(); - this.stopUsingUiCounterIndicies$.complete(); + this.pluginStop$.complete(); } private registerUsageCollectors( usageCollection: UsageCollectionSetup, coreSetup: CoreSetup, metric$: Subject, - stopUsingUiCounterIndicies$: Subject, + pluginStop$: Subject, registerType: SavedObjectsRegisterType ) { const getSavedObjectsClient = () => this.savedObjectsClient; @@ -101,12 +106,8 @@ export class KibanaUsageCollectionPlugin implements Plugin { const getCoreUsageDataService = () => this.coreUsageData!; registerUiCounterSavedObjectType(coreSetup.savedObjects); - registerUiCountersRollups( - this.logger.get('ui-counters'), - stopUsingUiCounterIndicies$, - getSavedObjectsClient - ); - registerUiCountersUsageCollector(usageCollection, stopUsingUiCounterIndicies$); + registerUiCountersRollups(this.logger.get('ui-counters'), pluginStop$, getSavedObjectsClient); + registerUiCountersUsageCollector(usageCollection, pluginStop$); registerUsageCountersRollups(this.logger.get('usage-counters-rollup'), getSavedObjectsClient); registerUsageCountersUsageCollector(usageCollection); @@ -127,5 +128,11 @@ export class KibanaUsageCollectionPlugin implements Plugin { registerCoreUsageCollector(usageCollection, getCoreUsageDataService); registerConfigUsageCollector(usageCollection, getCoreUsageDataService); registerLocalizationUsageCollector(usageCollection, coreSetup.i18n); + registerEventLoopDelaysCollector( + this.logger.get('event-loop-delays'), + usageCollection, + registerType, + getSavedObjectsClient + ); } } diff --git a/packages/kbn-interpreter/src/common/lib/get_type.d.ts b/src/plugins/management/common/index.ts similarity index 87% rename from packages/kbn-interpreter/src/common/lib/get_type.d.ts rename to src/plugins/management/common/index.ts index 568658c780333..c701ba846bcac 100644 --- a/packages/kbn-interpreter/src/common/lib/get_type.d.ts +++ b/src/plugins/management/common/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export declare function getType(node: any): string; +export { ManagementAppLocator } from './locator'; diff --git a/src/plugins/management/common/locator.test.ts b/src/plugins/management/common/locator.test.ts index dda393a4203ec..20773b9732782 100644 --- a/src/plugins/management/common/locator.test.ts +++ b/src/plugins/management/common/locator.test.ts @@ -7,16 +7,16 @@ */ import { MANAGEMENT_APP_ID } from './contants'; -import { ManagementAppLocator, MANAGEMENT_APP_LOCATOR } from './locator'; +import { ManagementAppLocatorDefinition, MANAGEMENT_APP_LOCATOR } from './locator'; test('locator has the right ID', () => { - const locator = new ManagementAppLocator(); + const locator = new ManagementAppLocatorDefinition(); expect(locator.id).toBe(MANAGEMENT_APP_LOCATOR); }); test('returns management app ID', async () => { - const locator = new ManagementAppLocator(); + const locator = new ManagementAppLocatorDefinition(); const location = await locator.getLocation({ sectionId: 'a', appId: 'b', @@ -28,26 +28,26 @@ test('returns management app ID', async () => { }); test('returns Kibana location for section ID and app ID pair', async () => { - const locator = new ManagementAppLocator(); + const locator = new ManagementAppLocatorDefinition(); const location = await locator.getLocation({ sectionId: 'ingest', appId: 'index', }); expect(location).toMatchObject({ - route: '/ingest/index', + path: '/ingest/index', state: {}, }); }); test('when app ID is not provided, returns path to just the section ID', async () => { - const locator = new ManagementAppLocator(); + const locator = new ManagementAppLocatorDefinition(); const location = await locator.getLocation({ sectionId: 'data', }); expect(location).toMatchObject({ - route: '/data', + path: '/data', state: {}, }); }); diff --git a/src/plugins/management/common/locator.ts b/src/plugins/management/common/locator.ts index 4a4a50f468adc..7dbf5e2888011 100644 --- a/src/plugins/management/common/locator.ts +++ b/src/plugins/management/common/locator.ts @@ -7,7 +7,7 @@ */ import { SerializableState } from 'src/plugins/kibana_utils/common'; -import { LocatorDefinition } from 'src/plugins/share/common'; +import { LocatorDefinition, LocatorPublic } from 'src/plugins/share/common'; import { MANAGEMENT_APP_ID } from './contants'; export const MANAGEMENT_APP_LOCATOR = 'MANAGEMENT_APP_LOCATOR'; @@ -17,15 +17,18 @@ export interface ManagementAppLocatorParams extends SerializableState { appId?: string; } -export class ManagementAppLocator implements LocatorDefinition { +export type ManagementAppLocator = LocatorPublic; + +export class ManagementAppLocatorDefinition + implements LocatorDefinition { public readonly id = MANAGEMENT_APP_LOCATOR; public readonly getLocation = async (params: ManagementAppLocatorParams) => { - const route = `/${params.sectionId}${params.appId ? '/' + params.appId : ''}`; + const path = `/${params.sectionId}${params.appId ? '/' + params.appId : ''}`; return { app: MANAGEMENT_APP_ID, - route, + path, state: {}, }; }; diff --git a/src/plugins/management/public/mocks/index.ts b/src/plugins/management/public/mocks/index.ts index 70d853f32dfcc..b06e41502e9df 100644 --- a/src/plugins/management/public/mocks/index.ts +++ b/src/plugins/management/public/mocks/index.ts @@ -33,9 +33,11 @@ const createSetupContract = (): ManagementSetup => ({ locator: { getLocation: jest.fn(async () => ({ app: 'MANAGEMENT', - route: '', + path: '', state: {}, })), + getUrl: jest.fn(), + useUrl: jest.fn(), navigate: jest.fn(), }, }); diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index 3289b2f6f5446..34719fb5070e1 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -25,7 +25,7 @@ import { } from '../../../core/public'; import { MANAGEMENT_APP_ID } from '../common/contants'; -import { ManagementAppLocator } from '../common/locator'; +import { ManagementAppLocatorDefinition } from '../common/locator'; import { ManagementSectionsService, getSectionsServiceStartPrivate, @@ -74,7 +74,7 @@ export class ManagementPlugin public setup(core: CoreSetup, { home, share }: ManagementSetupDependencies) { const kibanaVersion = this.initializerContext.env.packageInfo.version; - const locator = share.url.locators.create(new ManagementAppLocator()); + const locator = share.url.locators.create(new ManagementAppLocatorDefinition()); if (home) { home.featureCatalogue.register({ diff --git a/src/plugins/management/server/plugin.ts b/src/plugins/management/server/plugin.ts index 349cab6206bab..cc3798d855c59 100644 --- a/src/plugins/management/server/plugin.ts +++ b/src/plugins/management/server/plugin.ts @@ -9,7 +9,7 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from 'kibana/server'; import { LocatorPublic } from 'src/plugins/share/common'; import type { SharePluginSetup } from 'src/plugins/share/server'; -import { ManagementAppLocator, ManagementAppLocatorParams } from '../common/locator'; +import { ManagementAppLocatorDefinition, ManagementAppLocatorParams } from '../common/locator'; import { capabilitiesProvider } from './capabilities_provider'; interface ManagementSetupDependencies { @@ -31,7 +31,7 @@ export class ManagementServerPlugin public setup(core: CoreSetup, { share }: ManagementSetupDependencies) { this.logger.debug('management: Setup'); - const locator = share.url.locators.create(new ManagementAppLocator()); + const locator = share.url.locators.create(new ManagementAppLocatorDefinition()); core.capabilities.registerProvider(capabilitiesProvider); diff --git a/src/plugins/security_oss/server/check_cluster_data.test.ts b/src/plugins/security_oss/server/check_cluster_data.test.ts index 9e9459a68754c..6aa1cc9a28c39 100644 --- a/src/plugins/security_oss/server/check_cluster_data.test.ts +++ b/src/plugins/security_oss/server/check_cluster_data.test.ts @@ -27,20 +27,19 @@ describe('checkClusterForUserData', () => { it('returns false if data only exists in system indices', async () => { const esClient = elasticsearchServiceMock.createElasticsearchClient(); esClient.cat.indices.mockResolvedValue( - // @ts-expect-error @elastic/elasticsearch ES types don't support array response format elasticsearchServiceMock.createApiResponse({ body: [ { index: '.kibana', - 'docs.count': 500, + 'docs.count': '500', }, { index: 'kibana_sample_ecommerce_data', - 'docs.count': 20, + 'docs.count': '20', }, { index: '.somethingElse', - 'docs.count': 20, + 'docs.count': '20', }, ], }) @@ -56,16 +55,15 @@ describe('checkClusterForUserData', () => { it('returns true if data exists in non-system indices', async () => { const esClient = elasticsearchServiceMock.createElasticsearchClient(); esClient.cat.indices.mockResolvedValue( - // @ts-expect-error @elastic/elasticsearch ES types don't support array response format elasticsearchServiceMock.createApiResponse({ body: [ { index: '.kibana', - 'docs.count': 500, + 'docs.count': '500', }, { index: 'some_real_index', - 'docs.count': 20, + 'docs.count': '20', }, ], }) @@ -87,23 +85,21 @@ describe('checkClusterForUserData', () => { ) .mockRejectedValueOnce(new Error('something terrible happened')) .mockResolvedValueOnce( - // @ts-expect-error @elastic/elasticsearch ES types don't support array response format elasticsearchServiceMock.createApiResponse({ body: [ { index: '.kibana', - 'docs.count': 500, + 'docs.count': '500', }, ], }) ) .mockResolvedValueOnce( - // @ts-expect-error @elastic/elasticsearch ES types don't support array response format elasticsearchServiceMock.createApiResponse({ body: [ { index: 'some_real_index', - 'docs.count': 20, + 'docs.count': '20', }, ], }) diff --git a/src/plugins/share/common/index.ts b/src/plugins/share/common/index.ts index 8b5d8d4557194..e724117f5b7f7 100644 --- a/src/plugins/share/common/index.ts +++ b/src/plugins/share/common/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { LocatorDefinition, LocatorPublic } from './url_service'; +export { LocatorDefinition, LocatorPublic, useLocatorUrl } from './url_service'; diff --git a/src/plugins/share/common/url_service/__tests__/locators.test.ts b/src/plugins/share/common/url_service/__tests__/locators.test.ts index 45d727df7de48..93ba76c7399f4 100644 --- a/src/plugins/share/common/url_service/__tests__/locators.test.ts +++ b/src/plugins/share/common/url_service/__tests__/locators.test.ts @@ -53,7 +53,7 @@ describe('locators', () => { expect(location).toEqual({ app: 'test_app', - route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=21', + path: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=21', state: { isFlyoutOpen: true }, }); }); @@ -97,7 +97,7 @@ describe('locators', () => { expect(deps.navigate).toHaveBeenCalledWith( { app: 'test_app', - route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=1', + path: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=1', state: { isFlyoutOpen: false, }, @@ -130,7 +130,7 @@ describe('locators', () => { expect(deps.navigate).toHaveBeenCalledWith( { app: 'test_app', - route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=1', + path: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=1', state: { isFlyoutOpen: false, }, @@ -153,7 +153,7 @@ describe('locators', () => { expect(deps.navigate).toHaveBeenCalledWith( { app: 'test_app', - route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=2', + path: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=2', state: { isFlyoutOpen: false, }, diff --git a/src/plugins/share/common/url_service/__tests__/setup.ts b/src/plugins/share/common/url_service/__tests__/setup.ts index ad13bb8d8d216..fea3e1b945f99 100644 --- a/src/plugins/share/common/url_service/__tests__/setup.ts +++ b/src/plugins/share/common/url_service/__tests__/setup.ts @@ -21,7 +21,7 @@ export const testLocator: LocatorDefinition = { getLocation: async ({ savedObjectId, pageNumber, showFlyout }) => { return { app: 'test_app', - route: `/my-object/${savedObjectId}?page=${pageNumber}`, + path: `/my-object/${savedObjectId}?page=${pageNumber}`, state: { isFlyoutOpen: showFlyout, }, @@ -34,6 +34,9 @@ export const urlServiceTestSetup = (partialDeps: Partial navigate: async () => { throw new Error('not implemented'); }, + getUrl: async () => { + throw new Error('not implemented'); + }, ...partialDeps, }; const service = new UrlService(deps); diff --git a/src/plugins/share/common/url_service/locators/index.ts b/src/plugins/share/common/url_service/locators/index.ts index f9f87215eb4db..7ab3938984f23 100644 --- a/src/plugins/share/common/url_service/locators/index.ts +++ b/src/plugins/share/common/url_service/locators/index.ts @@ -9,3 +9,4 @@ export * from './types'; export * from './locator'; export * from './locator_client'; +export { useLocatorUrl } from './use_locator_url'; diff --git a/src/plugins/share/common/url_service/locators/locator.ts b/src/plugins/share/common/url_service/locators/locator.ts index 68c3b05a7f411..680fb2231fc48 100644 --- a/src/plugins/share/common/url_service/locators/locator.ts +++ b/src/plugins/share/common/url_service/locators/locator.ts @@ -7,16 +7,27 @@ */ import type { SavedObjectReference } from 'kibana/server'; +import { DependencyList } from 'react'; import type { PersistableState, SerializableState } from 'src/plugins/kibana_utils/common'; +import { useLocatorUrl } from './use_locator_url'; import type { LocatorDefinition, LocatorPublic, KibanaLocation, LocatorNavigationParams, + LocatorGetUrlParams, } from './types'; export interface LocatorDependencies { + /** + * Navigate without reloading the page to a KibanaLocation. + */ navigate: (location: KibanaLocation, params?: LocatorNavigationParams) => Promise; + + /** + * Resolve a Kibana URL given KibanaLocation. + */ + getUrl: (location: KibanaLocation, getUrlParams: LocatorGetUrlParams) => Promise; } export class Locator

implements PersistableState

, LocatorPublic

{ @@ -57,13 +68,29 @@ export class Locator

implements PersistableState

return await this.definition.getLocation(params); } + public async getUrl(params: P, { absolute = false }: LocatorGetUrlParams = {}): Promise { + const location = await this.getLocation(params); + const url = this.deps.getUrl(location, { absolute }); + + return url; + } + public async navigate( params: P, { replace = false }: LocatorNavigationParams = {} ): Promise { const location = await this.getLocation(params); + await this.deps.navigate(location, { replace, }); } + + /* eslint-disable react-hooks/rules-of-hooks */ + public readonly useUrl = ( + params: P, + getUrlParams?: LocatorGetUrlParams, + deps: DependencyList = [] + ): string => useLocatorUrl

(this, params, getUrlParams, deps); + /* eslint-enable react-hooks/rules-of-hooks */ } diff --git a/src/plugins/share/common/url_service/locators/types.ts b/src/plugins/share/common/url_service/locators/types.ts index d811ae0fd4aa2..870eaa3718d3f 100644 --- a/src/plugins/share/common/url_service/locators/types.ts +++ b/src/plugins/share/common/url_service/locators/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { DependencyList } from 'react'; import { PersistableState, SerializableState } from 'src/plugins/kibana_utils/common'; /** @@ -51,23 +52,57 @@ export interface LocatorDefinition

*/ export interface LocatorPublic

{ /** - * Returns a relative URL to the client-side redirect endpoint using this - * locator. (This method is necessary for compatibility with URL generators.) + * Returns a reference to a Kibana client-side location. + * + * @param params URL locator parameters. */ getLocation(params: P): Promise; + /** + * Returns a URL as a string. + * + * @param params URL locator parameters. + * @param getUrlParams URL construction parameters. + */ + getUrl(params: P, getUrlParams?: LocatorGetUrlParams): Promise; + /** * Navigate using the `core.application.navigateToApp()` method to a Kibana * location generated by this locator. This method is available only on the * browser. + * + * @param params URL locator parameters. + * @param navigationParams Navigation parameters. */ navigate(params: P, navigationParams?: LocatorNavigationParams): Promise; + + /** + * React hook which returns a URL string given locator parameters. Returns + * empty string if URL is being loaded or an error happened. + */ + useUrl: (params: P, getUrlParams?: LocatorGetUrlParams, deps?: DependencyList) => string; } +/** + * Parameters used when navigating on client-side using browser history object. + */ export interface LocatorNavigationParams { + /** + * Whether to replace a navigation entry in history queue or push a new entry. + */ replace?: boolean; } +/** + * Parameters used when constructing a string URL. + */ +export interface LocatorGetUrlParams { + /** + * Whether to return an absolute long URL or relative short URL. + */ + absolute?: boolean; +} + /** * This interface represents a location in Kibana to which one can navigate * using the `core.application.navigateToApp()` method. @@ -79,9 +114,9 @@ export interface KibanaLocation { app: string; /** - * A URL route within a Kibana application. + * A relative URL path within a Kibana application. */ - route: string; + path: string; /** * A serializable location state object, which the app can use to determine diff --git a/src/plugins/share/common/url_service/locators/use_locator_url.ts b/src/plugins/share/common/url_service/locators/use_locator_url.ts new file mode 100644 index 0000000000000..a84c712e16248 --- /dev/null +++ b/src/plugins/share/common/url_service/locators/use_locator_url.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 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 { DependencyList, useEffect, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { SerializableState } from 'src/plugins/kibana_utils/common'; +import { LocatorGetUrlParams, LocatorPublic } from '../../../common/url_service'; + +export const useLocatorUrl =

( + locator: LocatorPublic

| null | undefined, + params: P, + getUrlParams?: LocatorGetUrlParams, + deps: DependencyList = [] +): string => { + const [url, setUrl] = useState(''); + const isMounted = useMountedState(); + + /* eslint-disable react-hooks/exhaustive-deps */ + useEffect(() => { + if (!locator) { + setUrl(''); + return; + } + + locator + .getUrl(params, getUrlParams) + .then((result: string) => { + if (!isMounted()) return; + setUrl(result); + }) + .catch((error) => { + if (!isMounted()) return; + // eslint-disable-next-line no-console + console.error('useLocatorUrl', error); + setUrl(''); + }); + }, [locator, ...deps]); + /* eslint-enable react-hooks/exhaustive-deps */ + + return url; +}; diff --git a/src/plugins/share/common/url_service/url_service.ts b/src/plugins/share/common/url_service/url_service.ts index 0c3a0aabb750b..5daba1500cdfd 100644 --- a/src/plugins/share/common/url_service/url_service.ts +++ b/src/plugins/share/common/url_service/url_service.ts @@ -17,7 +17,9 @@ export class UrlService { /** * Client to work with locators. */ - locators: LocatorClient = new LocatorClient(this.deps); + public readonly locators: LocatorClient; - constructor(protected readonly deps: UrlServiceDependencies) {} + constructor(protected readonly deps: UrlServiceDependencies) { + this.locators = new LocatorClient(deps); + } } diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index d13bb15f8c72c..8f5356f6a2201 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -29,6 +29,8 @@ export { UrlGeneratorsService, } from './url_generators'; +export { useLocatorUrl } from '../common/url_service/locators/use_locator_url'; + import { SharePlugin } from './plugin'; export { KibanaURL } from './kibana_url'; diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index eb7c46cdaef86..893108b56bcfa 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -68,14 +68,22 @@ export class SharePlugin implements Plugin { core.application.register(createShortUrlRedirectApp(core, window.location)); this.url = new UrlService({ - navigate: async (location, { replace = false } = {}) => { + navigate: async ({ app, path, state }, { replace = false } = {}) => { const [start] = await core.getStartServices(); - await start.application.navigateToApp(location.app, { - path: location.route, - state: location.state, + await start.application.navigateToApp(app, { + path, + state, replace, }); }, + getUrl: async ({ app, path }, { absolute }) => { + const start = await core.getStartServices(); + const url = start[0].application.getUrlForApp(app, { + path, + absolute, + }); + return url; + }, }); return { diff --git a/src/plugins/share/server/plugin.ts b/src/plugins/share/server/plugin.ts index 6e3c68935f77b..76e10372cdb67 100644 --- a/src/plugins/share/server/plugin.ts +++ b/src/plugins/share/server/plugin.ts @@ -32,7 +32,10 @@ export class SharePlugin implements Plugin { public setup(core: CoreSetup) { this.url = new UrlService({ navigate: async () => { - throw new Error('Locator .navigate() does not work on server.'); + throw new Error('Locator .navigate() currently is not supported on the server.'); + }, + getUrl: async () => { + throw new Error('Locator .getUrl() currently is not supported on the server.'); }, }); diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 6ab550389a12d..99c6dcb40e57d 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7900,6 +7900,93 @@ } } }, + "event_loop_delays": { + "properties": { + "daily": { + "type": "array", + "items": { + "properties": { + "processId": { + "type": "long", + "_meta": { + "description": "The process id of the monitored kibana instance." + } + }, + "fromTimestamp": { + "type": "date", + "_meta": { + "description": "Timestamp at which the histogram started monitoring." + } + }, + "lastUpdatedAt": { + "type": "date", + "_meta": { + "description": "Latest timestamp this histogram object was updated this day." + } + }, + "min": { + "type": "long", + "_meta": { + "description": "The minimum recorded event loop delay." + } + }, + "max": { + "type": "long", + "_meta": { + "description": "The maximum recorded event loop delay." + } + }, + "mean": { + "type": "long", + "_meta": { + "description": "The mean of the recorded event loop delays." + } + }, + "exceeds": { + "type": "long", + "_meta": { + "description": "The number of times the event loop delay exceeded the maximum 1 hour eventloop delay threshold." + } + }, + "stddev": { + "type": "long", + "_meta": { + "description": "The standard deviation of the recorded event loop delays." + } + }, + "percentiles": { + "properties": { + "50": { + "type": "long", + "_meta": { + "description": "The 50th accumulated percentile distribution" + } + }, + "75": { + "type": "long", + "_meta": { + "description": "The 75th accumulated percentile distribution" + } + }, + "95": { + "type": "long", + "_meta": { + "description": "The 95th accumulated percentile distribution" + } + }, + "99": { + "type": "long", + "_meta": { + "description": "The 99th accumulated percentile distribution" + } + } + } + } + } + } + } + } + }, "localization": { "properties": { "locale": { diff --git a/test/functional/apps/discover/_discover_fields_api.ts b/test/functional/apps/discover/_discover_fields_api.ts index 614a0794ffb3b..42e2a94b36462 100644 --- a/test/functional/apps/discover/_discover_fields_api.ts +++ b/test/functional/apps/discover/_discover_fields_api.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from './ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); + const docTable = getService('docTable'); const retry = getService('retry'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); @@ -58,5 +59,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.discover.getDocHeader()).not.to.have.string('_score'); expect(await PageObjects.discover.getDocHeader()).to.have.string('Document'); }); + + it('displays _source viewer in doc viewer', async function () { + await docTable.clickRowToggle({ rowIndex: 0 }); + + await PageObjects.discover.isShowingDocViewer(); + await PageObjects.discover.clickDocViewerTab(1); + await PageObjects.discover.expectSourceViewerToExist(); + }); }); } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 41c4441a1c95d..65b899d2e2fb0 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -289,6 +289,14 @@ export class DiscoverPageObject extends FtrService { return await this.testSubjects.exists('kbnDocViewer'); } + public async clickDocViewerTab(index: number) { + return await this.find.clickByCssSelector(`#kbn_doc_viewer_tab_${index}`); + } + + public async expectSourceViewerToExist() { + return await this.find.byClassName('monaco-editor'); + } + public async getMarks() { const table = await this.docTable.getTable(); const marks = await table.findAllByTagName('mark'); diff --git a/test/functional/page_objects/time_to_visualize_page.ts b/test/functional/page_objects/time_to_visualize_page.ts index 287b03ec60d88..57a22103f6409 100644 --- a/test/functional/page_objects/time_to_visualize_page.ts +++ b/test/functional/page_objects/time_to_visualize_page.ts @@ -51,7 +51,10 @@ export class TimeToVisualizePageObject extends FtrService { vizName: string, { saveAsNew, redirectToOrigin, addToDashboard, dashboardId, saveToLibrary }: SaveModalArgs = {} ) { - await this.testSubjects.setValue('savedObjectTitle', vizName); + await this.testSubjects.setValue('savedObjectTitle', vizName, { + typeCharByChar: true, + clearWithKeyboard: true, + }); const hasSaveAsNew = await this.testSubjects.exists('saveAsNewCheckbox'); if (hasSaveAsNew && saveAsNew !== undefined) { diff --git a/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts b/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts index c750602f735bd..8fc09ce2d7342 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts @@ -71,6 +71,21 @@ export default function ({ expect(getCell(result, 0, 2)).to.be(4618); }); + it('shifts multiple metrics with relative time range and previous', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='now'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggCount id="1" enabled=true schema="metric"} + aggs={aggCount id="2" enabled=true schema="metric" timeShift="previous"} + `; + const result = await expectExpression( + 'esaggs_shift_multi_metric_previous', + expression + ).getResponse(); + expect(getCell(result, 0, 0)).to.be(9247); + expect(getCell(result, 0, 1)).to.be(4763); + }); + it('shifts single percentile', async () => { const expression = ` kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} @@ -137,7 +152,7 @@ export default function ({ customMetric={aggAvg id="3" field="bytes" enabled=true - schema="metric" + schema="metric" } enabled=true schema="metric" @@ -154,7 +169,7 @@ export default function ({ customMetric={aggAvg id="5" field="bytes" enabled=true - schema="metric" + schema="metric" } enabled=true schema="metric" diff --git a/x-pack/package.json b/x-pack/package.json index 84fd5ba081d8f..1397a3da81072 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -27,11 +27,6 @@ }, "devDependencies": { "@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers", - "@kbn/storybook": "link:../packages/kbn-storybook", "@kbn/test": "link:../packages/kbn-test" - }, - "dependencies": { - "@kbn/interpreter": "link:../packages/kbn-interpreter", - "@kbn/ui-framework": "link:../packages/kbn-ui-framework" } } \ No newline at end of file diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts index c81fa7927ef7d..53d888967c431 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts @@ -63,7 +63,7 @@ import { parseDuration } from '../../common/parse_duration'; import { retryIfConflicts } from '../lib/retry_if_conflicts'; import { partiallyUpdateAlert } from '../saved_objects'; import { markApiKeyForInvalidation } from '../invalidate_pending_api_keys/mark_api_key_for_invalidation'; -import { alertAuditEvent, AlertAuditAction } from './audit_events'; +import { ruleAuditEvent, RuleAuditAction } from './audit_events'; import { KueryNode, nodeBuilder } from '../../../../../src/plugins/data/common'; import { mapSortField } from './lib'; import { getAlertExecutionStatusPending } from '../lib/alert_execution_status'; @@ -253,8 +253,8 @@ export class AlertsClient { }); } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.CREATE, + ruleAuditEvent({ + action: RuleAuditAction.CREATE, savedObject: { type: 'alert', id }, error, }) @@ -305,8 +305,8 @@ export class AlertsClient { }; this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.CREATE, + ruleAuditEvent({ + action: RuleAuditAction.CREATE, outcome: 'unknown', savedObject: { type: 'alert', id }, }) @@ -375,8 +375,8 @@ export class AlertsClient { }); } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.GET, + ruleAuditEvent({ + action: RuleAuditAction.GET, savedObject: { type: 'alert', id }, error, }) @@ -384,8 +384,8 @@ export class AlertsClient { throw error; } this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.GET, + ruleAuditEvent({ + action: RuleAuditAction.GET, savedObject: { type: 'alert', id }, }) ); @@ -467,8 +467,8 @@ export class AlertsClient { ); } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.FIND, + ruleAuditEvent({ + action: RuleAuditAction.FIND, error, }) ); @@ -508,8 +508,8 @@ export class AlertsClient { ); } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.FIND, + ruleAuditEvent({ + action: RuleAuditAction.FIND, savedObject: { type: 'alert', id }, error, }) @@ -525,8 +525,8 @@ export class AlertsClient { authorizedData.forEach(({ id }) => this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.FIND, + ruleAuditEvent({ + action: RuleAuditAction.FIND, savedObject: { type: 'alert', id }, }) ) @@ -620,8 +620,8 @@ export class AlertsClient { }); } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.DELETE, + ruleAuditEvent({ + action: RuleAuditAction.DELETE, savedObject: { type: 'alert', id }, error, }) @@ -630,8 +630,8 @@ export class AlertsClient { } this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.DELETE, + ruleAuditEvent({ + action: RuleAuditAction.DELETE, outcome: 'unknown', savedObject: { type: 'alert', id }, }) @@ -694,8 +694,8 @@ export class AlertsClient { }); } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.UPDATE, + ruleAuditEvent({ + action: RuleAuditAction.UPDATE, savedObject: { type: 'alert', id }, error, }) @@ -704,8 +704,8 @@ export class AlertsClient { } this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.UPDATE, + ruleAuditEvent({ + action: RuleAuditAction.UPDATE, outcome: 'unknown', savedObject: { type: 'alert', id }, }) @@ -870,8 +870,8 @@ export class AlertsClient { } } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.UPDATE_API_KEY, + ruleAuditEvent({ + action: RuleAuditAction.UPDATE_API_KEY, savedObject: { type: 'alert', id }, error, }) @@ -900,8 +900,8 @@ export class AlertsClient { }); this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.UPDATE_API_KEY, + ruleAuditEvent({ + action: RuleAuditAction.UPDATE_API_KEY, outcome: 'unknown', savedObject: { type: 'alert', id }, }) @@ -976,8 +976,8 @@ export class AlertsClient { } } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.ENABLE, + ruleAuditEvent({ + action: RuleAuditAction.ENABLE, savedObject: { type: 'alert', id }, error, }) @@ -986,8 +986,8 @@ export class AlertsClient { } this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.ENABLE, + ruleAuditEvent({ + action: RuleAuditAction.ENABLE, outcome: 'unknown', savedObject: { type: 'alert', id }, }) @@ -1090,8 +1090,8 @@ export class AlertsClient { }); } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.DISABLE, + ruleAuditEvent({ + action: RuleAuditAction.DISABLE, savedObject: { type: 'alert', id }, error, }) @@ -1100,8 +1100,8 @@ export class AlertsClient { } this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.DISABLE, + ruleAuditEvent({ + action: RuleAuditAction.DISABLE, outcome: 'unknown', savedObject: { type: 'alert', id }, }) @@ -1167,8 +1167,8 @@ export class AlertsClient { } } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.MUTE, + ruleAuditEvent({ + action: RuleAuditAction.MUTE, savedObject: { type: 'alert', id }, error, }) @@ -1177,8 +1177,8 @@ export class AlertsClient { } this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.MUTE, + ruleAuditEvent({ + action: RuleAuditAction.MUTE, outcome: 'unknown', savedObject: { type: 'alert', id }, }) @@ -1229,8 +1229,8 @@ export class AlertsClient { } } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.UNMUTE, + ruleAuditEvent({ + action: RuleAuditAction.UNMUTE, savedObject: { type: 'alert', id }, error, }) @@ -1239,8 +1239,8 @@ export class AlertsClient { } this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.UNMUTE, + ruleAuditEvent({ + action: RuleAuditAction.UNMUTE, outcome: 'unknown', savedObject: { type: 'alert', id }, }) @@ -1291,8 +1291,8 @@ export class AlertsClient { } } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.MUTE_INSTANCE, + ruleAuditEvent({ + action: RuleAuditAction.MUTE_ALERT, savedObject: { type: 'alert', id: alertId }, error, }) @@ -1301,8 +1301,8 @@ export class AlertsClient { } this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.MUTE_INSTANCE, + ruleAuditEvent({ + action: RuleAuditAction.MUTE_ALERT, outcome: 'unknown', savedObject: { type: 'alert', id: alertId }, }) @@ -1358,8 +1358,8 @@ export class AlertsClient { } } catch (error) { this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.UNMUTE_INSTANCE, + ruleAuditEvent({ + action: RuleAuditAction.UNMUTE_ALERT, savedObject: { type: 'alert', id: alertId }, error, }) @@ -1368,8 +1368,8 @@ export class AlertsClient { } this.auditLogger?.log( - alertAuditEvent({ - action: AlertAuditAction.UNMUTE_INSTANCE, + ruleAuditEvent({ + action: RuleAuditAction.UNMUTE_ALERT, outcome: 'unknown', savedObject: { type: 'alert', id: alertId }, }) diff --git a/x-pack/plugins/alerting/server/alerts_client/audit_events.test.ts b/x-pack/plugins/alerting/server/alerts_client/audit_events.test.ts index 4ccb69832cd26..781b8fe1f4715 100644 --- a/x-pack/plugins/alerting/server/alerts_client/audit_events.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/audit_events.test.ts @@ -5,13 +5,13 @@ * 2.0. */ -import { AlertAuditAction, alertAuditEvent } from './audit_events'; +import { RuleAuditAction, ruleAuditEvent } from './audit_events'; -describe('#alertAuditEvent', () => { +describe('#ruleAuditEvent', () => { test('creates event with `unknown` outcome', () => { expect( - alertAuditEvent({ - action: AlertAuditAction.CREATE, + ruleAuditEvent({ + action: RuleAuditAction.CREATE, outcome: 'unknown', savedObject: { type: 'alert', id: 'ALERT_ID' }, }) @@ -19,7 +19,7 @@ describe('#alertAuditEvent', () => { Object { "error": undefined, "event": Object { - "action": "alert_create", + "action": "rule_create", "category": Array [ "database", ], @@ -34,22 +34,22 @@ describe('#alertAuditEvent', () => { "type": "alert", }, }, - "message": "User is creating alert [id=ALERT_ID]", + "message": "User is creating rule [id=ALERT_ID]", } `); }); test('creates event with `success` outcome', () => { expect( - alertAuditEvent({ - action: AlertAuditAction.CREATE, + ruleAuditEvent({ + action: RuleAuditAction.CREATE, savedObject: { type: 'alert', id: 'ALERT_ID' }, }) ).toMatchInlineSnapshot(` Object { "error": undefined, "event": Object { - "action": "alert_create", + "action": "rule_create", "category": Array [ "database", ], @@ -64,15 +64,15 @@ describe('#alertAuditEvent', () => { "type": "alert", }, }, - "message": "User has created alert [id=ALERT_ID]", + "message": "User has created rule [id=ALERT_ID]", } `); }); test('creates event with `failure` outcome', () => { expect( - alertAuditEvent({ - action: AlertAuditAction.CREATE, + ruleAuditEvent({ + action: RuleAuditAction.CREATE, savedObject: { type: 'alert', id: 'ALERT_ID' }, error: new Error('ERROR_MESSAGE'), }) @@ -83,7 +83,7 @@ describe('#alertAuditEvent', () => { "message": "ERROR_MESSAGE", }, "event": Object { - "action": "alert_create", + "action": "rule_create", "category": Array [ "database", ], @@ -98,7 +98,7 @@ describe('#alertAuditEvent', () => { "type": "alert", }, }, - "message": "Failed attempt to create alert [id=ALERT_ID]", + "message": "Failed attempt to create rule [id=ALERT_ID]", } `); }); diff --git a/x-pack/plugins/alerting/server/alerts_client/audit_events.ts b/x-pack/plugins/alerting/server/alerts_client/audit_events.ts index 93cca255d6ebc..f04b7c3701974 100644 --- a/x-pack/plugins/alerting/server/alerts_client/audit_events.ts +++ b/x-pack/plugins/alerting/server/alerts_client/audit_events.ts @@ -8,67 +8,67 @@ import { EcsEventOutcome, EcsEventType } from 'src/core/server'; import { AuditEvent } from '../../../security/server'; -export enum AlertAuditAction { - CREATE = 'alert_create', - GET = 'alert_get', - UPDATE = 'alert_update', - UPDATE_API_KEY = 'alert_update_api_key', - ENABLE = 'alert_enable', - DISABLE = 'alert_disable', - DELETE = 'alert_delete', - FIND = 'alert_find', - MUTE = 'alert_mute', - UNMUTE = 'alert_unmute', - MUTE_INSTANCE = 'alert_instance_mute', - UNMUTE_INSTANCE = 'alert_instance_unmute', +export enum RuleAuditAction { + CREATE = 'rule_create', + GET = 'rule_get', + UPDATE = 'rule_update', + UPDATE_API_KEY = 'rule_update_api_key', + ENABLE = 'rule_enable', + DISABLE = 'rule_disable', + DELETE = 'rule_delete', + FIND = 'rule_find', + MUTE = 'rule_mute', + UNMUTE = 'rule_unmute', + MUTE_ALERT = 'rule_alert_mute', + UNMUTE_ALERT = 'rule_alert_unmute', } type VerbsTuple = [string, string, string]; -const eventVerbs: Record = { - alert_create: ['create', 'creating', 'created'], - alert_get: ['access', 'accessing', 'accessed'], - alert_update: ['update', 'updating', 'updated'], - alert_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'], - alert_enable: ['enable', 'enabling', 'enabled'], - alert_disable: ['disable', 'disabling', 'disabled'], - alert_delete: ['delete', 'deleting', 'deleted'], - alert_find: ['access', 'accessing', 'accessed'], - alert_mute: ['mute', 'muting', 'muted'], - alert_unmute: ['unmute', 'unmuting', 'unmuted'], - alert_instance_mute: ['mute instance of', 'muting instance of', 'muted instance of'], - alert_instance_unmute: ['unmute instance of', 'unmuting instance of', 'unmuted instance of'], +const eventVerbs: Record = { + rule_create: ['create', 'creating', 'created'], + rule_get: ['access', 'accessing', 'accessed'], + rule_update: ['update', 'updating', 'updated'], + rule_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'], + rule_enable: ['enable', 'enabling', 'enabled'], + rule_disable: ['disable', 'disabling', 'disabled'], + rule_delete: ['delete', 'deleting', 'deleted'], + rule_find: ['access', 'accessing', 'accessed'], + rule_mute: ['mute', 'muting', 'muted'], + rule_unmute: ['unmute', 'unmuting', 'unmuted'], + rule_alert_mute: ['mute alert of', 'muting alert of', 'muted alert of'], + rule_alert_unmute: ['unmute alert of', 'unmuting alert of', 'unmuted alert of'], }; -const eventTypes: Record = { - alert_create: 'creation', - alert_get: 'access', - alert_update: 'change', - alert_update_api_key: 'change', - alert_enable: 'change', - alert_disable: 'change', - alert_delete: 'deletion', - alert_find: 'access', - alert_mute: 'change', - alert_unmute: 'change', - alert_instance_mute: 'change', - alert_instance_unmute: 'change', +const eventTypes: Record = { + rule_create: 'creation', + rule_get: 'access', + rule_update: 'change', + rule_update_api_key: 'change', + rule_enable: 'change', + rule_disable: 'change', + rule_delete: 'deletion', + rule_find: 'access', + rule_mute: 'change', + rule_unmute: 'change', + rule_alert_mute: 'change', + rule_alert_unmute: 'change', }; -export interface AlertAuditEventParams { - action: AlertAuditAction; +export interface RuleAuditEventParams { + action: RuleAuditAction; outcome?: EcsEventOutcome; savedObject?: NonNullable['saved_object']; error?: Error; } -export function alertAuditEvent({ +export function ruleAuditEvent({ action, savedObject, outcome, error, -}: AlertAuditEventParams): AuditEvent { - const doc = savedObject ? `alert [id=${savedObject.id}]` : 'an alert'; +}: RuleAuditEventParams): AuditEvent { + const doc = savedObject ? `rule [id=${savedObject.id}]` : 'a rule'; const [present, progressive, past] = eventVerbs[action]; const message = error ? `Failed attempt to ${present} ${doc}` diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts index a2d5a5e0386c4..793357215d382 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts @@ -226,7 +226,7 @@ describe('create()', () => { }); describe('auditLogger', () => { - test('logs audit event when creating an alert', async () => { + test('logs audit event when creating a rule', async () => { const data = getMockData({ enabled: false, actions: [], @@ -241,7 +241,7 @@ describe('create()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_create', + action: 'rule_create', outcome: 'unknown', }), kibana: { saved_object: { id: 'mock-saved-object-id', type: 'alert' } }, @@ -249,7 +249,7 @@ describe('create()', () => { ); }); - test('logs audit event when not authorised to create an alert', async () => { + test('logs audit event when not authorised to create a rule', async () => { authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); await expect( @@ -263,7 +263,7 @@ describe('create()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_create', + action: 'rule_create', outcome: 'failure', }), kibana: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/delete.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/delete.test.ts index 0f9d91d829854..ca0f0cf0fb5a6 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/delete.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/delete.test.ts @@ -258,12 +258,12 @@ describe('delete()', () => { }); describe('auditLogger', () => { - test('logs audit event when deleting an alert', async () => { + test('logs audit event when deleting a rule', async () => { await alertsClient.delete({ id: '1' }); expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_delete', + action: 'rule_delete', outcome: 'unknown', }), kibana: { saved_object: { id: '1', type: 'alert' } }, @@ -271,14 +271,14 @@ describe('delete()', () => { ); }); - test('logs audit event when not authorised to delete an alert', async () => { + test('logs audit event when not authorised to delete a rule', async () => { authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); await expect(alertsClient.delete({ id: '1' })).rejects.toThrow(); expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_delete', + action: 'rule_delete', outcome: 'failure', }), kibana: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/disable.test.ts index 7eb107c2f4dec..da1c5ea8bfe8d 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/disable.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/disable.test.ts @@ -126,12 +126,12 @@ describe('disable()', () => { }); describe('auditLogger', () => { - test('logs audit event when disabling an alert', async () => { + test('logs audit event when disabling a rule', async () => { await alertsClient.disable({ id: '1' }); expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_disable', + action: 'rule_disable', outcome: 'unknown', }), kibana: { saved_object: { id: '1', type: 'alert' } }, @@ -139,14 +139,14 @@ describe('disable()', () => { ); }); - test('logs audit event when not authorised to disable an alert', async () => { + test('logs audit event when not authorised to disable a rule', async () => { authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); await expect(alertsClient.disable({ id: '1' })).rejects.toThrow(); expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_disable', + action: 'rule_disable', outcome: 'failure', }), kibana: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts index 8329e52d7444a..b3c8d3bd83980 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts @@ -165,12 +165,12 @@ describe('enable()', () => { }); describe('auditLogger', () => { - test('logs audit event when enabling an alert', async () => { + test('logs audit event when enabling a rule', async () => { await alertsClient.enable({ id: '1' }); expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_enable', + action: 'rule_enable', outcome: 'unknown', }), kibana: { saved_object: { id: '1', type: 'alert' } }, @@ -178,14 +178,14 @@ describe('enable()', () => { ); }); - test('logs audit event when not authorised to enable an alert', async () => { + test('logs audit event when not authorised to enable a rule', async () => { authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); await expect(alertsClient.enable({ id: '1' })).rejects.toThrow(); expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_enable', + action: 'rule_enable', outcome: 'failure', }), kibana: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts index 8fa8ae7ae38b0..fe788cd43bc2b 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts @@ -277,13 +277,13 @@ describe('find()', () => { }); describe('auditLogger', () => { - test('logs audit event when searching alerts', async () => { + test('logs audit event when searching rules', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); await alertsClient.find(); expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_find', + action: 'rule_find', outcome: 'success', }), kibana: { saved_object: { id: '1', type: 'alert' } }, @@ -291,7 +291,7 @@ describe('find()', () => { ); }); - test('logs audit event when not authorised to search alerts', async () => { + test('logs audit event when not authorised to search rules', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); authorization.getFindAuthorizationFilter.mockRejectedValue(new Error('Unauthorized')); @@ -299,7 +299,7 @@ describe('find()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_find', + action: 'rule_find', outcome: 'failure', }), error: { @@ -310,7 +310,7 @@ describe('find()', () => { ); }); - test('logs audit event when not authorised to search alert type', async () => { + test('logs audit event when not authorised to search rule type', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); authorization.getFindAuthorizationFilter.mockResolvedValue({ ensureRuleTypeIsAuthorized: jest.fn(() => { @@ -323,7 +323,7 @@ describe('find()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_find', + action: 'rule_find', outcome: 'failure', }), kibana: { saved_object: { id: '1', type: 'alert' } }, diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/get.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/get.test.ts index a958ea4061ae5..1be9d3e3ba2c9 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/get.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/get.test.ts @@ -226,13 +226,13 @@ describe('get()', () => { }); }); - test('logs audit event when getting an alert', async () => { + test('logs audit event when getting a rule', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); await alertsClient.get({ id: '1' }); expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_get', + action: 'rule_get', outcome: 'success', }), kibana: { saved_object: { id: '1', type: 'alert' } }, @@ -240,7 +240,7 @@ describe('get()', () => { ); }); - test('logs audit event when not authorised to get an alert', async () => { + test('logs audit event when not authorised to get a rule', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); @@ -248,7 +248,7 @@ describe('get()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_get', + action: 'rule_get', outcome: 'failure', }), kibana: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/mute_all.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/mute_all.test.ts index 6734ec9b99600..43f43b539ebf2 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/mute_all.test.ts @@ -155,7 +155,7 @@ describe('muteAll()', () => { }); describe('auditLogger', () => { - test('logs audit event when muting an alert', async () => { + test('logs audit event when muting a rule', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -181,7 +181,7 @@ describe('muteAll()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_mute', + action: 'rule_mute', outcome: 'unknown', }), kibana: { saved_object: { id: '1', type: 'alert' } }, @@ -189,7 +189,7 @@ describe('muteAll()', () => { ); }); - test('logs audit event when not authorised to mute an alert', async () => { + test('logs audit event when not authorised to mute a rule', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -217,7 +217,7 @@ describe('muteAll()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_mute', + action: 'rule_mute', outcome: 'failure', }), kibana: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/mute_instance.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/mute_instance.test.ts index bc0b7288e952f..e2e4aff61866b 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/mute_instance.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/mute_instance.test.ts @@ -189,7 +189,7 @@ describe('muteInstance()', () => { }); describe('auditLogger', () => { - test('logs audit event when muting an alert instance', async () => { + test('logs audit event when muting an alert', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -209,7 +209,7 @@ describe('muteInstance()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_instance_mute', + action: 'rule_alert_mute', outcome: 'unknown', }), kibana: { saved_object: { id: '1', type: 'alert' } }, @@ -217,7 +217,7 @@ describe('muteInstance()', () => { ); }); - test('logs audit event when not authorised to mute an alert instance', async () => { + test('logs audit event when not authorised to mute an alert', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -241,7 +241,7 @@ describe('muteInstance()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_instance_mute', + action: 'rule_alert_mute', outcome: 'failure', }), kibana: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/unmute_all.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/unmute_all.test.ts index c061bc7840fb6..02439d3cd6bad 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/unmute_all.test.ts @@ -155,7 +155,7 @@ describe('unmuteAll()', () => { }); describe('auditLogger', () => { - test('logs audit event when unmuting an alert', async () => { + test('logs audit event when unmuting a rule', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -181,7 +181,7 @@ describe('unmuteAll()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_unmute', + action: 'rule_unmute', outcome: 'unknown', }), kibana: { saved_object: { id: '1', type: 'alert' } }, @@ -189,7 +189,7 @@ describe('unmuteAll()', () => { ); }); - test('logs audit event when not authorised to unmute an alert', async () => { + test('logs audit event when not authorised to unmute a rule', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -217,7 +217,7 @@ describe('unmuteAll()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_unmute', + action: 'rule_unmute', outcome: 'failure', }), kibana: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/unmute_instance.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/unmute_instance.test.ts index 4da83b6441a8d..3f3ec697a9478 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/unmute_instance.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/unmute_instance.test.ts @@ -187,7 +187,7 @@ describe('unmuteInstance()', () => { }); describe('auditLogger', () => { - test('logs audit event when unmuting an alert instance', async () => { + test('logs audit event when unmuting an alert', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -207,7 +207,7 @@ describe('unmuteInstance()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_instance_unmute', + action: 'rule_alert_unmute', outcome: 'unknown', }), kibana: { saved_object: { id: '1', type: 'alert' } }, @@ -215,7 +215,7 @@ describe('unmuteInstance()', () => { ); }); - test('logs audit event when not authorised to unmute an alert instance', async () => { + test('logs audit event when not authorised to unmute an alert', async () => { const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -239,7 +239,7 @@ describe('unmuteInstance()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_instance_unmute', + action: 'rule_alert_unmute', outcome: 'failure', }), kibana: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts index c743312ef2c4b..350c9ed31298f 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts @@ -1476,7 +1476,7 @@ describe('update()', () => { }); }); - test('logs audit event when updating an alert', async () => { + test('logs audit event when updating a rule', async () => { await alertsClient.update({ id: '1', data: { @@ -1495,7 +1495,7 @@ describe('update()', () => { expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_update', + action: 'rule_update', outcome: 'unknown', }), kibana: { saved_object: { id: '1', type: 'alert' } }, @@ -1503,7 +1503,7 @@ describe('update()', () => { ); }); - test('logs audit event when not authorised to update an alert', async () => { + test('logs audit event when not authorised to update a rule', async () => { authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); await expect( @@ -1526,7 +1526,7 @@ describe('update()', () => { expect.objectContaining({ event: expect.objectContaining({ outcome: 'failure', - action: 'alert_update', + action: 'rule_update', }), kibana: { saved_object: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts index 4215f14b4a560..15aa0dbc64eb8 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts @@ -295,13 +295,13 @@ describe('updateApiKey()', () => { }); describe('auditLogger', () => { - test('logs audit event when updating the API key of an alert', async () => { + test('logs audit event when updating the API key of a rule', async () => { await alertsClient.updateApiKey({ id: '1' }); expect(auditLogger.log).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - action: 'alert_update_api_key', + action: 'rule_update_api_key', outcome: 'unknown', }), kibana: { saved_object: { id: '1', type: 'alert' } }, @@ -309,7 +309,7 @@ describe('updateApiKey()', () => { ); }); - test('logs audit event when not authorised to update the API key of an alert', async () => { + test('logs audit event when not authorised to update the API key of a rule', async () => { authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); await expect(alertsClient.updateApiKey({ id: '1' })).rejects.toThrow(); @@ -317,7 +317,7 @@ describe('updateApiKey()', () => { expect.objectContaining({ event: expect.objectContaining({ outcome: 'failure', - action: 'alert_update_api_key', + action: 'rule_update_api_key', }), kibana: { saved_object: { diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index e33c410668c25..21aef379715c7 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -43,4 +43,4 @@ "ml", "observability" ] -} +} \ No newline at end of file diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx index da55f274bd77c..11926dd965f95 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx @@ -85,7 +85,7 @@ export function DetailView({ errorGroup, urlParams }: Props) { const status = error.http?.response?.status_code; return ( - +

diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index 0f2180721afe3..3d22c3863c100 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -146,9 +146,13 @@ export function ErrorGroupDetails({ return ( <> + + - + + + {showDetails && ( diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx index 95ec80b1a51bc..886ef8412f35b 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx @@ -73,7 +73,7 @@ export function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { return ( - + - +

{i18n.translate( diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx index 5b4f4e24af44d..ca73f6ddd05b3 100644 --- a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx @@ -18,7 +18,7 @@ import { AlertType } from '../../../../common/alert_types'; import { AlertingFlyout } from '../../alerting/alerting_flyout'; const alertLabel = i18n.translate('xpack.apm.home.alertsMenu.alerts', { - defaultMessage: 'Alerts', + defaultMessage: 'Alerts and rules', }); const transactionDurationLabel = i18n.translate( 'xpack.apm.home.alertsMenu.transactionDuration', @@ -33,11 +33,11 @@ const errorCountLabel = i18n.translate('xpack.apm.home.alertsMenu.errorCount', { }); const createThresholdAlertLabel = i18n.translate( 'xpack.apm.home.alertsMenu.createThresholdAlert', - { defaultMessage: 'Create threshold alert' } + { defaultMessage: 'Create threshold rule' } ); const createAnomalyAlertAlertLabel = i18n.translate( 'xpack.apm.home.alertsMenu.createAnomalyAlert', - { defaultMessage: 'Create anomaly alert' } + { defaultMessage: 'Create anomaly rule' } ); const CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID = @@ -102,7 +102,7 @@ export function AlertingPopoverAndFlyout({ { name: i18n.translate( 'xpack.apm.home.alertsMenu.viewActiveAlerts', - { defaultMessage: 'View active alerts' } + { defaultMessage: 'Manage rules' } ), href: basePath.prepend( '/app/management/insightsAndAlerting/triggersActions/alerts' diff --git a/x-pack/plugins/apm/server/lib/fleet/register_fleet_policy_callbacks.ts b/x-pack/plugins/apm/server/lib/fleet/register_fleet_policy_callbacks.ts index 35c7f0dfdfd73..c122a5c406eab 100644 --- a/x-pack/plugins/apm/server/lib/fleet/register_fleet_policy_callbacks.ts +++ b/x-pack/plugins/apm/server/lib/fleet/register_fleet_policy_callbacks.ts @@ -12,6 +12,7 @@ import { APMPluginStartDependencies } from '../../types'; import { ExternalCallback } from '../../../../fleet/server'; import { AGENT_NAME } from '../../../common/elasticsearch_fieldnames'; import { AgentConfiguration } from '../../../common/agent_configuration/configuration_types'; +import { getPackagePolicyWithSourceMap, listArtifacts } from './source_maps'; export async function registerFleetPolicyCallbacks({ plugins, @@ -31,7 +32,7 @@ export async function registerFleetPolicyCallbacks({ // Registers a callback invoked when a policy is created to populate the APM // integration policy with pre-existing agent configurations - registerAgentConfigExternalCallback({ + registerPackagePolicyExternalCallback({ fleetPluginStart, callbackName: 'packagePolicyCreate', plugins, @@ -42,7 +43,7 @@ export async function registerFleetPolicyCallbacks({ // Registers a callback invoked when a policy is updated to populate the APM // integration policy with existing agent configurations - registerAgentConfigExternalCallback({ + registerPackagePolicyExternalCallback({ fleetPluginStart, callbackName: 'packagePolicyUpdate', plugins, @@ -53,11 +54,11 @@ export async function registerFleetPolicyCallbacks({ } type ExternalCallbackParams = Parameters; -type PackagePolicy = ExternalCallbackParams[0]; +export type PackagePolicy = ExternalCallbackParams[0]; type Context = ExternalCallbackParams[1]; type Request = ExternalCallbackParams[2]; -function registerAgentConfigExternalCallback({ +function registerPackagePolicyExternalCallback({ fleetPluginStart, callbackName, plugins, @@ -91,8 +92,9 @@ function registerAgentConfigExternalCallback({ ruleDataClient, }); const agentConfigurations = await listConfigurations({ setup }); + const artifacts = await listArtifacts({ fleetPluginStart }); return getPackagePolicyWithAgentConfigurations( - packagePolicy, + getPackagePolicyWithSourceMap({ packagePolicy, artifacts }), agentConfigurations ); }; @@ -100,7 +102,7 @@ function registerAgentConfigExternalCallback({ fleetPluginStart.registerExternalCallback(callbackName, callbackFn); } -const APM_SERVER = 'apm-server'; +export const APM_SERVER = 'apm-server'; // Immutable function applies the given package policy with a set of agent configurations export function getPackagePolicyWithAgentConfigurations( diff --git a/x-pack/plugins/apm/server/lib/fleet/source_maps.test.ts b/x-pack/plugins/apm/server/lib/fleet/source_maps.test.ts new file mode 100644 index 0000000000000..61a4fa4436e69 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/fleet/source_maps.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { + ArtifactSourceMap, + getPackagePolicyWithSourceMap, +} from './source_maps'; + +const packagePolicy = { + id: '123', + version: 'WzMxNDI2LDFd', + name: 'apm-1', + description: '', + namespace: 'default', + policy_id: '7a87c160-c961-11eb-81e2-f7327d61c92a', + enabled: true, + output_id: '', + inputs: [ + { + policy_template: 'apmserver', + streams: [], + vars: {}, + type: 'apm', + enabled: true, + compiled_input: { + 'apm-server': { + capture_personal_data: true, + max_event_size: 307200, + api_key: { limit: 100, enabled: false }, + default_service_environment: null, + host: 'localhost:8200', + kibana: { api_key: null }, + secret_token: null, + }, + }, + }, + ], + package: { name: 'apm', title: 'Elastic APM', version: '0.2.0' }, + created_at: '2021-06-16T14:54:32.195Z', + created_by: 'elastic', +}; + +const artifacts = [ + { + type: 'sourcemap', + identifier: 'service_name-1.0.0', + relative_url: '/api/fleet/artifacts/service_name-1.0.0/my-id-1', + body: { + serviceName: 'service_name', + serviceVersion: '1.0.0', + bundleFilepath: 'http://localhost:3000/static/js/main.chunk.js', + sourceMap: { + version: 3, + file: 'static/js/main.chunk.js', + sources: ['foo'], + sourcesContent: ['foo'], + mappings: 'foo', + sourceRoot: '', + }, + }, + created: '2021-06-16T15:03:55.049Z', + id: 'apm:service_name-1.0.0-my-id-1', + compressionAlgorithm: 'zlib', + decodedSha256: 'my-id-1', + decodedSize: 9440, + encodedSha256: 'sha123', + encodedSize: 2622, + encryptionAlgorithm: 'none', + packageName: 'apm', + }, + { + type: 'sourcemap', + identifier: 'service_name-2.0.0', + relative_url: '/api/fleet/artifacts/service_name-2.0.0/my-id-2', + body: { + serviceName: 'service_name', + serviceVersion: '2.0.0', + bundleFilepath: 'http://localhost:3000/static/js/main.chunk.js', + sourceMap: { + version: 3, + file: 'static/js/main.chunk.js', + sources: ['foo'], + sourcesContent: ['foo'], + mappings: 'foo', + sourceRoot: '', + }, + }, + created: '2021-06-16T15:03:55.049Z', + id: 'apm:service_name-2.0.0-my-id-2', + compressionAlgorithm: 'zlib', + decodedSha256: 'my-id-2', + decodedSize: 9440, + encodedSha256: 'sha456', + encodedSize: 2622, + encryptionAlgorithm: 'none', + packageName: 'apm', + }, +] as ArtifactSourceMap[]; + +describe('Source maps', () => { + describe('getPackagePolicyWithSourceMap', () => { + it('returns unchanged package policy when artifacts is empty', () => { + const updatedPackagePolicy = getPackagePolicyWithSourceMap({ + packagePolicy, + artifacts: [], + }); + expect(updatedPackagePolicy).toEqual(packagePolicy); + }); + it('adds source maps into the package policy', () => { + const updatedPackagePolicy = getPackagePolicyWithSourceMap({ + packagePolicy, + artifacts, + }); + expect(updatedPackagePolicy.inputs[0].config).toEqual({ + 'apm-server': { + value: { + rum: { + source_mapping: { + metadata: [ + { + 'service.name': 'service_name', + 'service.version': '1.0.0', + 'bundle.filepath': + 'http://localhost:3000/static/js/main.chunk.js', + 'sourcemap.url': + '/api/fleet/artifacts/service_name-1.0.0/my-id-1', + }, + { + 'service.name': 'service_name', + 'service.version': '2.0.0', + 'bundle.filepath': + 'http://localhost:3000/static/js/main.chunk.js', + 'sourcemap.url': + '/api/fleet/artifacts/service_name-2.0.0/my-id-2', + }, + ], + }, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/fleet/source_maps.ts b/x-pack/plugins/apm/server/lib/fleet/source_maps.ts new file mode 100644 index 0000000000000..b313fbad2806f --- /dev/null +++ b/x-pack/plugins/apm/server/lib/fleet/source_maps.ts @@ -0,0 +1,174 @@ +/* + * 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 * as t from 'io-ts'; +import { + CoreSetup, + CoreStart, + ElasticsearchClient, + SavedObjectsClientContract, +} from 'kibana/server'; +import { promisify } from 'util'; +import { unzip } from 'zlib'; +import { Artifact } from '../../../../fleet/server'; +import { sourceMapRt } from '../../routes/source_maps'; +import { APMPluginStartDependencies } from '../../types'; +import { getApmPackgePolicies } from './get_apm_package_policies'; +import { APM_SERVER, PackagePolicy } from './register_fleet_policy_callbacks'; + +export interface ApmArtifactBody { + serviceName: string; + serviceVersion: string; + bundleFilepath: string; + sourceMap: t.TypeOf; +} +export type ArtifactSourceMap = Omit & { + body: ApmArtifactBody; +}; + +export type FleetPluginStart = NonNullable; + +const doUnzip = promisify(unzip); + +function decodeArtifacts(artifacts: Artifact[]): Promise { + return Promise.all( + artifacts.map(async (artifact) => { + const body = await doUnzip(Buffer.from(artifact.body, 'base64')); + return { + ...artifact, + body: JSON.parse(body.toString()) as ApmArtifactBody, + }; + }) + ); +} + +function getApmArtifactClient(fleetPluginStart: FleetPluginStart) { + return fleetPluginStart.createArtifactsClient('apm'); +} + +export async function listArtifacts({ + fleetPluginStart, +}: { + fleetPluginStart: FleetPluginStart; +}) { + const apmArtifactClient = getApmArtifactClient(fleetPluginStart); + const artifacts = await apmArtifactClient.listArtifacts({ + kuery: 'type: sourcemap', + }); + + return decodeArtifacts(artifacts.items); +} + +export async function createApmArtifact({ + apmArtifactBody, + fleetPluginStart, +}: { + apmArtifactBody: ApmArtifactBody; + fleetPluginStart: FleetPluginStart; +}) { + const apmArtifactClient = getApmArtifactClient(fleetPluginStart); + const identifier = `${apmArtifactBody.serviceName}-${apmArtifactBody.serviceVersion}`; + + return apmArtifactClient.createArtifact({ + type: 'sourcemap', + identifier, + content: JSON.stringify(apmArtifactBody), + }); +} + +export async function deleteApmArtifact({ + id, + fleetPluginStart, +}: { + id: string; + fleetPluginStart: FleetPluginStart; +}) { + const apmArtifactClient = getApmArtifactClient(fleetPluginStart); + return apmArtifactClient.deleteArtifact(id); +} + +export function getPackagePolicyWithSourceMap({ + packagePolicy, + artifacts, +}: { + packagePolicy: PackagePolicy; + artifacts: ArtifactSourceMap[]; +}) { + if (!artifacts.length) { + return packagePolicy; + } + const [firstInput, ...restInputs] = packagePolicy.inputs; + return { + ...packagePolicy, + inputs: [ + { + ...firstInput, + config: { + ...firstInput.config, + [APM_SERVER]: { + value: { + ...firstInput?.config?.[APM_SERVER].value, + rum: { + source_mapping: { + metadata: artifacts.map((artifact) => ({ + 'service.name': artifact.body.serviceName, + 'service.version': artifact.body.serviceVersion, + 'bundle.filepath': artifact.body.bundleFilepath, + 'sourcemap.url': artifact.relative_url, + })), + }, + }, + }, + }, + }, + }, + ...restInputs, + ], + }; +} + +export async function updateSourceMapsOnFleetPolicies({ + core, + fleetPluginStart, + savedObjectsClient, + elasticsearchClient, +}: { + core: { setup: CoreSetup; start: () => Promise }; + fleetPluginStart: FleetPluginStart; + savedObjectsClient: SavedObjectsClientContract; + elasticsearchClient: ElasticsearchClient; +}) { + const artifacts = await listArtifacts({ fleetPluginStart }); + + const apmFleetPolicies = await getApmPackgePolicies({ + core, + fleetPluginStart, + }); + + return Promise.all( + apmFleetPolicies.items.map(async (item) => { + const { + id, + revision, + updated_at: updatedAt, + updated_by: updatedBy, + ...packagePolicy + } = item; + + const updatedPackagePolicy = getPackagePolicyWithSourceMap({ + packagePolicy, + artifacts, + }); + + await fleetPluginStart.packagePolicyService.update( + savedObjectsClient, + elasticsearchClient, + id, + updatedPackagePolicy + ); + }) + ); +} diff --git a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts index c151752b4b6e0..f1c08444d2e1e 100644 --- a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts +++ b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts @@ -26,6 +26,7 @@ import { agentConfigurationRouteRepository } from './settings/agent_configuratio import { anomalyDetectionRouteRepository } from './settings/anomaly_detection'; import { apmIndicesRouteRepository } from './settings/apm_indices'; import { customLinkRouteRepository } from './settings/custom_link'; +import { sourceMapsRouteRepository } from './source_maps'; import { traceRouteRepository } from './traces'; import { transactionRouteRepository } from './transactions'; import { APMRouteHandlerResources } from './typings'; @@ -48,7 +49,8 @@ const getTypedGlobalApmServerRouteRepository = () => { .merge(agentConfigurationRouteRepository) .merge(anomalyDetectionRouteRepository) .merge(apmIndicesRouteRepository) - .merge(customLinkRouteRepository); + .merge(customLinkRouteRepository) + .merge(sourceMapsRouteRepository); return repository; }; diff --git a/x-pack/plugins/apm/server/routes/source_maps.ts b/x-pack/plugins/apm/server/routes/source_maps.ts new file mode 100644 index 0000000000000..24ea825774b0a --- /dev/null +++ b/x-pack/plugins/apm/server/routes/source_maps.ts @@ -0,0 +1,138 @@ +/* + * 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 Boom from '@hapi/boom'; +import * as t from 'io-ts'; +import { SavedObjectsClientContract } from 'kibana/server'; +import { + createApmArtifact, + deleteApmArtifact, + listArtifacts, + updateSourceMapsOnFleetPolicies, +} from '../lib/fleet/source_maps'; +import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; + +export const sourceMapRt = t.intersection([ + t.type({ + version: t.number, + sources: t.array(t.string), + mappings: t.string, + }), + t.partial({ + names: t.array(t.string), + file: t.string, + sourceRoot: t.string, + sourcesContent: t.array(t.string), + }), +]); + +const listSourceMapRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/sourcemaps', + options: { tags: ['access:apm'] }, + handler: async ({ plugins, logger }) => { + try { + const fleetPluginStart = await plugins.fleet?.start(); + if (fleetPluginStart) { + const artifacts = await listArtifacts({ fleetPluginStart }); + return { artifacts }; + } + } catch (e) { + throw Boom.internal( + 'Something went wrong while fetching artifacts source maps', + e + ); + } + }, +}); + +const uploadSourceMapRoute = createApmServerRoute({ + endpoint: 'POST /api/apm/sourcemaps/{serviceName}/{serviceVersion}', + options: { tags: ['access:apm', 'access:apm_write'] }, + params: t.type({ + path: t.type({ + serviceName: t.string, + serviceVersion: t.string, + }), + body: t.type({ + bundleFilepath: t.string, + sourceMap: sourceMapRt, + }), + }), + handler: async ({ params, plugins, core }) => { + const { serviceName, serviceVersion } = params.path; + const { bundleFilepath, sourceMap } = params.body; + const fleetPluginStart = await plugins.fleet?.start(); + const coreStart = await core.start(); + const esClient = coreStart.elasticsearch.client.asInternalUser; + const savedObjectsClient = await getInternalSavedObjectsClient(core.setup); + try { + if (fleetPluginStart) { + const artifact = await createApmArtifact({ + fleetPluginStart, + apmArtifactBody: { + serviceName, + serviceVersion, + bundleFilepath, + sourceMap, + }, + }); + await updateSourceMapsOnFleetPolicies({ + core, + fleetPluginStart, + savedObjectsClient: (savedObjectsClient as unknown) as SavedObjectsClientContract, + elasticsearchClient: esClient, + }); + + return artifact; + } + } catch (e) { + throw Boom.internal( + 'Something went wrong while creating a new source map', + e + ); + } + }, +}); + +const deleteSourceMapRoute = createApmServerRoute({ + endpoint: 'DELETE /api/apm/sourcemaps/{id}', + options: { tags: ['access:apm', 'access:apm_write'] }, + params: t.type({ + path: t.type({ + id: t.string, + }), + }), + handler: async ({ context, params, plugins, core }) => { + const fleetPluginStart = await plugins.fleet?.start(); + const { id } = params.path; + const coreStart = await core.start(); + const esClient = coreStart.elasticsearch.client.asInternalUser; + const savedObjectsClient = await getInternalSavedObjectsClient(core.setup); + try { + if (fleetPluginStart) { + await deleteApmArtifact({ id, fleetPluginStart }); + await updateSourceMapsOnFleetPolicies({ + core, + fleetPluginStart, + savedObjectsClient: (savedObjectsClient as unknown) as SavedObjectsClientContract, + elasticsearchClient: esClient, + }); + } + } catch (e) { + throw Boom.internal( + `Something went wrong while deleting source map. id: ${id}`, + e + ); + } + }, +}); + +export const sourceMapsRouteRepository = createApmServerRouteRepository() + .add(listSourceMapRoute) + .add(uploadSourceMapRoute) + .add(deleteSourceMapRoute); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx index 97b5e592552ed..fbcba9e56aef5 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx @@ -45,9 +45,12 @@ export const dropdownFilter: RendererFactory = () => ({ reuseDomNode: true, height: 50, render(domNode, config, handlers) { - const filterExpression = handlers.getFilter(); + let filterExpression = handlers.getFilter(); - if (filterExpression !== '') { + if (filterExpression === undefined || filterExpression.indexOf('exactly')) { + filterExpression = ''; + handlers.setFilter(filterExpression); + } else if (filterExpression !== '') { // NOTE: setFilter() will cause a data refresh, avoid calling unless required // compare expression and filter, update filter if needed const { changed, newAst } = syncFilterExpression(config, filterExpression, ['filterGroup']); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx index ff781bb294db4..02a36b80fa364 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx @@ -19,6 +19,8 @@ import { RendererFactory } from '../../../../types'; const { timeFilter: strings } = RendererStrings; +const defaultTimeFilterExpression = 'timefilter column=@timestamp from=now-24h to=now'; + export const timeFilterFactory: StartInitializer> = (core, plugins) => { const { uiSettings } = core; @@ -38,9 +40,12 @@ export const timeFilterFactory: StartInitializer> = ( help: strings.getHelpDescription(), reuseDomNode: true, // must be true, otherwise popovers don't work render: async (domNode: HTMLElement, config: Arguments, handlers: RendererHandlers) => { - const filterExpression = handlers.getFilter(); + let filterExpression = handlers.getFilter(); - if (filterExpression !== '') { + if (filterExpression === undefined || filterExpression.indexOf('timefilter') !== 0) { + filterExpression = defaultTimeFilterExpression; + handlers.setFilter(filterExpression); + } else if (filterExpression !== '') { // NOTE: setFilter() will cause a data refresh, avoid calling unless required // compare expression and filter, update filter if needed const { changed, newAst } = syncFilterExpression(config, filterExpression, [ diff --git a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.component.tsx index 8f92db4e7f3f4..8fb24c1f3c62e 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.component.tsx @@ -85,10 +85,6 @@ export interface Props { * Current autoplay interval */ autoplayInterval: number; - /** - * Enables autoplay - */ - enableAutoplay: (autoplay: boolean) => void; /** * Sets autoplay interval */ @@ -110,7 +106,6 @@ export const ViewMenu: FunctionComponent = ({ setRefreshInterval, autoplayEnabled, autoplayInterval, - enableAutoplay, setAutoplayInterval, }) => { const setRefresh = (val: number) => setRefreshInterval(val); @@ -259,6 +254,5 @@ ViewMenu.propTypes = { setRefreshInterval: PropTypes.func.isRequired, autoplayEnabled: PropTypes.bool.isRequired, autoplayInterval: PropTypes.number.isRequired, - enableAutoplay: PropTypes.func.isRequired, setAutoplayInterval: PropTypes.func.isRequired, }; diff --git a/x-pack/plugins/canvas/public/functions/to.ts b/x-pack/plugins/canvas/public/functions/to.ts index dcb1972c6bdfb..907d1f3d3a635 100644 --- a/x-pack/plugins/canvas/public/functions/to.ts +++ b/x-pack/plugins/canvas/public/functions/to.ts @@ -5,7 +5,6 @@ * 2.0. */ -// @ts-expect-error untyped Elastic library import { castProvider } from '@kbn/interpreter/common'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/public'; import { getFunctionHelp, getFunctionErrors } from '../../i18n'; diff --git a/x-pack/plugins/canvas/public/lib/create_handlers.ts b/x-pack/plugins/canvas/public/lib/create_handlers.ts index 1b0d188edf48f..bf4419ab7b8a7 100644 --- a/x-pack/plugins/canvas/public/lib/create_handlers.ts +++ b/x-pack/plugins/canvas/public/lib/create_handlers.ts @@ -90,7 +90,7 @@ export const createDispatchedHandlerFactory = ( }, getFilter() { - return element.filter; + return element.filter || ''; }, onComplete(fn: () => void | boolean) { diff --git a/x-pack/plugins/canvas/public/registries.ts b/x-pack/plugins/canvas/public/registries.ts index 8484380830422..1ad7fa6905c22 100644 --- a/x-pack/plugins/canvas/public/registries.ts +++ b/x-pack/plugins/canvas/public/registries.ts @@ -5,7 +5,6 @@ * 2.0. */ -// @ts-expect-error untyped module import { addRegistries, register } from '@kbn/interpreter/common'; // @ts-expect-error untyped local import { elementsRegistry } from './lib/elements_registry'; diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_history.test.ts b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_history.test.ts index b5b9c038cfd2d..515da36ddbb36 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_history.test.ts +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_history.test.ts @@ -26,76 +26,234 @@ describe('useRestoreHistory', () => { jest.resetAllMocks(); }); - test('replaces undefined state with current state', () => { - const history = { - location: { - state: undefined, - pathname: 'somepath', - }, - push: jest.fn(), - replace: jest.fn(), - }; - - const state = { - persistent: { some: 'state' }, - }; - - mockGetState.mockReturnValue(state); - mockGetHistory.mockReturnValue(history); - - renderHook(() => useWorkpadHistory()); - - expect(history.replace).toBeCalledWith(history.location.pathname, encode(state.persistent)); + describe('initial run', () => { + test('with undefined location state ', () => { + const history = { + location: { + state: undefined, + pathname: 'somepath', + }, + push: jest.fn(), + replace: jest.fn(), + }; + + const state = { + persistent: { some: 'state' }, + }; + + mockGetState.mockReturnValue(state); + mockGetHistory.mockReturnValue(history); + + renderHook(() => useWorkpadHistory()); + + expect(history.replace).toBeCalledWith(history.location.pathname, encode(state.persistent)); + expect(history.push).not.toBeCalled(); + }); + + test('with location state not matching store state', () => { + const history = { + location: { + state: encode({ prior: 'state' }), + pathname: 'somepath', + }, + push: jest.fn(), + replace: jest.fn(), + }; + + const state = { + persistent: { some: 'state' }, + }; + + mockGetState.mockReturnValue(state); + mockGetHistory.mockReturnValue(history); + + renderHook(() => useWorkpadHistory()); + + expect(history.push).not.toBeCalled(); + expect(history.replace).not.toBeCalled(); + }); + + test('with location state matching store state', () => { + const state = { some: 'state' }; + const history = { + location: { + state: encode(state), + pathname: 'somepath', + }, + push: jest.fn(), + replace: jest.fn(), + }; + mockGetState.mockReturnValue(state); + mockGetHistory.mockReturnValue(history); + + renderHook(() => useWorkpadHistory()); + + expect(history.push).not.toBeCalled(); + expect(history.replace).not.toBeCalled(); + }); }); - test('does not do a push on initial render if states do not match', () => { - const history = { - location: { - state: encode({ old: 'state' }), - pathname: 'somepath', - }, - push: jest.fn(), - replace: jest.fn(), - }; + describe('state changes', () => { + it('does a replace if location state is undefined', () => { + const push = jest.fn(); + const replace = jest.fn(); + + const history = { + location: { + state: encode({ old: 'state' }), + pathname: 'somepath', + search: '', + }, + push, + replace, + }; + + const state = { + persistent: { some: 'state' }, + }; + + const newState = { + persistent: { new: 'state' }, + }; + + mockGetState.mockReturnValue(state); + mockGetHistory.mockReturnValue(history); + + const { rerender } = renderHook(() => useWorkpadHistory()); + + mockGetState.mockReturnValue(newState); + // History object from react router will not change, so just modifying here + history.location.state = undefined; + history.location.pathname = 'newpath'; + rerender(); - const state = { - persistent: { some: 'state' }, - }; + expect(history.replace).toBeCalledWith('newpath', encode(newState.persistent)); + }); - mockGetState.mockReturnValue(state); - mockGetHistory.mockReturnValue(history); + test('does a push if location state does not match store state', () => { + const history = { + location: { + state: encode({ old: 'state' }), + pathname: 'somepath', + }, + push: jest.fn(), + replace: jest.fn(), + }; - renderHook(() => useWorkpadHistory()); + const oldState = { + persistent: { some: 'state' }, + }; - expect(history.push).not.toBeCalled(); + const newState = { + persistent: { new: 'state' }, + }; + + mockGetState.mockReturnValue(oldState); + mockGetHistory.mockReturnValue(history); + + const { rerender } = renderHook(() => useWorkpadHistory()); + + mockGetState.mockReturnValue(newState); + rerender(); + + expect(history.push).toBeCalledWith(history.location.pathname, encode(newState.persistent)); + }); + + test('does nothing if new state matches location state', () => { + const state = { + persistent: { some: 'state' }, + }; + + const newState = { ...state }; + + const history = { + location: { + state: encode(state.persistent), + pathname: 'somepath', + }, + push: jest.fn(), + replace: jest.fn(), + }; + + mockGetState.mockReturnValue(state); + mockGetHistory.mockReturnValue(history); + + const { rerender } = renderHook(() => useWorkpadHistory()); + + mockGetState.mockReturnValue(newState); + rerender(); + + expect(history.push).not.toBeCalled(); + expect(history.replace).not.toBeCalled(); + }); }); - test('rerender does a push if location state does not match store state', () => { - const history = { - location: { - state: encode({ old: 'state' }), - pathname: 'somepath', - }, - push: jest.fn(), - replace: jest.fn(), - }; + describe('changes to location', () => { + test('changes to pathname have no effect', () => { + // This is equivalent of navigating to a new page. + // The location state will initially be undefined, but + // we don't want to take any action because it will cause a state change + // and that will be picked up and do the replace + const state = { + persistent: { some: 'state' }, + }; + + const history = { + location: { + state: encode(state.persistent), + pathname: 'somepath', + }, + push: jest.fn(), + replace: jest.fn(), + }; + + mockGetState.mockReturnValue(state); + mockGetHistory.mockReturnValue(history); + + const { rerender } = renderHook(() => useWorkpadHistory()); + + history.location.state = undefined; + history.location.pathname = 'newpath'; + + rerender(); + + expect(history.push).not.toBeCalled(); + expect(history.replace).not.toBeCalled(); + }); - const oldState = { - persistent: { some: 'state' }, - }; + test('changes to search does a replace', () => { + // This is equivalent of going from full screen to not full screen + // There is no state change that will occur, but we still need to update + // the location state + const state = { + persistent: { some: 'state' }, + }; - const newState = { - persistent: { new: 'state' }, - }; + const history = { + location: { + state: encode(state.persistent), + pathname: 'somepath', + search: '', + }, + push: jest.fn(), + replace: jest.fn(), + }; - mockGetState.mockReturnValue(oldState); - mockGetHistory.mockReturnValue(history); + mockGetState.mockReturnValue(state); + mockGetHistory.mockReturnValue(history); - const { rerender } = renderHook(() => useWorkpadHistory()); + const { rerender } = renderHook(() => useWorkpadHistory()); + history.location.pathname = 'somepath'; + history.location.search = 'newsearch'; + history.location.state = undefined; - mockGetState.mockReturnValue(newState); - rerender(); + rerender(); - expect(history.push).toBeCalledWith(history.location.pathname, encode(newState.persistent)); + expect(history.push).not.toBeCalled(); + expect(history.replace).toBeCalledWith( + `somepath?${history.location.search}`, + encode(state.persistent) + ); + }); }); }); diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_history.ts b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_history.ts index 1f563f7147330..b8880be60e36a 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_history.ts +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_history.ts @@ -31,11 +31,10 @@ export const useWorkpadHistory = () => { // This will happen when navigating directly to a url (there will be no state on that link click) if (locationState === undefined) { history.replace(fullPath, encode(historyState)); - } else if (!doesStateMatchLocationState && !isInitialRun) { + } else if (!isInitialRun && !doesStateMatchLocationState) { // There was a state change here - // If the state of the route that we are on does not match this new state, then we are going to push history.push(fullPath, encode(historyState)); } - }, [history, historyState]); + }, [history, historyState, history.location.search]); }; diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index 43e4bc6bdb64f..e1cebeb65bd21 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -6,7 +6,6 @@ */ import { get, omit } from 'lodash'; -// @ts-expect-error untyped local import { safeElementFromExpression, fromExpression } from '@kbn/interpreter/common'; import { append } from '../../lib/modify_path'; import { getAssets } from './assets'; diff --git a/x-pack/plugins/cases/kibana.json b/x-pack/plugins/cases/kibana.json index 4a85a64c7e03a..6439f28b958d0 100644 --- a/x-pack/plugins/cases/kibana.json +++ b/x-pack/plugins/cases/kibana.json @@ -1,18 +1,31 @@ { - "configPath": ["xpack", "cases"], - "id": "cases", - "kibanaVersion": "kibana", - "extraPublicDirs": ["common"], - "requiredPlugins": [ - "actions", - "esUiShared", - "features", - "kibanaReact", - "kibanaUtils", - "triggersActionsUi" + "configPath":[ + "cases", + "xpack" ], - "optionalPlugins": ["spaces", "security"], - "server": true, - "ui": true, - "version": "8.0.0" + "description":"The Case management system in Kibana", + "extraPublicDirs":[ + "common" + ], + "id":"cases", + "kibanaVersion":"kibana", + "optionalPlugins":[ + "security", + "spaces" + ], + "owner":{ + "githubTeam":"security-threat-hunting", + "name":"Security Solution Threat Hunting" + }, + "requiredPlugins":[ + "actions", + "esUiShared", + "features", + "kibanaReact", + "kibanaUtils", + "triggersActionsUi" + ], + "server":true, + "ui":true, + "version":"8.0.0" } diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index 1a60521667bba..ca41db577700e 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -12,7 +12,7 @@ export * from '../../common/translations'; export const INCIDENT_MANAGEMENT_SYSTEM_TITLE = i18n.translate( 'xpack.cases.configureCases.incidentManagementSystemTitle', { - defaultMessage: 'Connect to external incident management system', + defaultMessage: 'External incident management system', } ); @@ -20,7 +20,7 @@ export const INCIDENT_MANAGEMENT_SYSTEM_DESC = i18n.translate( 'xpack.cases.configureCases.incidentManagementSystemDesc', { defaultMessage: - 'You may optionally connect cases to an external incident management system of your choosing. This will allow you to push case data as an incident in your chosen third-party system.', + 'Connect your cases to an external incident management system. You can then push case data as an incident in a third-party system.', } ); @@ -38,7 +38,7 @@ export const ADD_NEW_CONNECTOR = i18n.translate('xpack.cases.configureCases.addN export const CASE_CLOSURE_OPTIONS_TITLE = i18n.translate( 'xpack.cases.configureCases.caseClosureOptionsTitle', { - defaultMessage: 'Case Closures', + defaultMessage: 'Case closures', } ); @@ -46,14 +46,14 @@ export const CASE_CLOSURE_OPTIONS_DESC = i18n.translate( 'xpack.cases.configureCases.caseClosureOptionsDesc', { defaultMessage: - 'Define how you wish cases to be closed. Automated case closures require an established connection to an external incident management system.', + 'Define how to close your cases. Automatic closures require an established connection to an external incident management system.', } ); export const CASE_CLOSURE_OPTIONS_SUB_CASES = i18n.translate( 'xpack.cases.configureCases.caseClosureOptionsSubCases', { - defaultMessage: 'Automated closures of sub-cases is not currently supported.', + defaultMessage: 'Automatic closure of sub-cases is not supported.', } ); diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js index 86abbba968781..e49751cecc1d0 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js @@ -39,10 +39,6 @@ describe('Create Auto-follow pattern', () => { expect(exists('remoteClustersLoading')).toBe(true); expect(find('remoteClustersLoading').text()).toBe('Loading remote clusters…'); }); - - test('should have a link to the documentation', () => { - expect(exists('docsButton')).toBe(true); - }); }); describe('when remote clusters are loaded', () => { @@ -59,6 +55,10 @@ describe('Create Auto-follow pattern', () => { component.update(); }); + test('should have a link to the documentation', () => { + expect(exists('docsButton')).toBe(true); + }); + test('should display the Auto-follow pattern form', async () => { expect(exists('autoFollowPatternForm')).toBe(true); }); diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js index 228868194b231..6d54444df4273 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js @@ -42,10 +42,6 @@ describe('Create Follower index', () => { expect(exists('remoteClustersLoading')).toBe(true); expect(find('remoteClustersLoading').text()).toBe('Loading remote clusters…'); }); - - test('should have a link to the documentation', () => { - expect(exists('docsButton')).toBe(true); - }); }); describe('when remote clusters are loaded', () => { @@ -62,6 +58,10 @@ describe('Create Follower index', () => { component.update(); }); + test('should have a link to the documentation', () => { + expect(exists('docsButton')).toBe(true); + }); + test('should display the Follower index form', async () => { expect(exists('followerIndexForm')).toBe(true); }); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/app.tsx b/x-pack/plugins/cross_cluster_replication/public/app/app.tsx index 50a6cfb1b4bb9..c6144143e1849 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/app.tsx +++ b/x-pack/plugins/cross_cluster_replication/public/app/app.tsx @@ -5,27 +5,19 @@ * 2.0. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import { Route, Switch, Router, Redirect } from 'react-router-dom'; import { ScopedHistory, ApplicationStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiPageContent, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; +import { EuiEmptyPrompt, EuiPageContent } from '@elastic/eui'; import { getFatalErrors } from './services/notifications'; -import { SectionError } from './components'; import { routing } from './services/routing'; // @ts-ignore import { loadPermissions } from './services/api'; +import { SectionLoading, PageError } from '../shared_imports'; // @ts-ignore import { @@ -119,48 +111,34 @@ class AppComponent extends Component { if (isFetchingPermissions) { return ( - - - - - - - - -

- -

-
-
-
+ + + + ); } if (fetchPermissionError) { return ( - - - } - error={fetchPermissionError} - /> - - - + + } + error={fetchPermissionError} + /> ); } if (!hasPermission) { return ( - + ( - - + <> + {title}} + rightSideItems={[ + + + , + ]} + /> - - - - -

{title}

-
-
- - - - - - -
-
-
+ + ); AutoFollowPatternPageTitle.propTypes = { diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js index b5652d3f2b6e6..6d523cf2c470f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js @@ -5,51 +5,38 @@ * 2.0. */ -import React, { Fragment } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiPageContentHeader, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; +import { EuiSpacer, EuiPageHeader, EuiButtonEmpty } from '@elastic/eui'; import { documentationLinks } from '../services/documentation_links'; export const FollowerIndexPageTitle = ({ title }) => ( - - + <> + {title}} + rightSideItems={[ + + + , + ]} + /> - - - - -

{title}

-
-
- - - - - - -
-
-
+ + ); FollowerIndexPageTitle.propTypes = { diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js index 0fe562b7a8f05..118e3103008d0 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js @@ -8,16 +8,15 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { EuiPageContent } from '@elastic/eui'; +import { EuiPageContentBody, EuiPageContent } from '@elastic/eui'; import { listBreadcrumb, addBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, RemoteClustersProvider, - SectionLoading, } from '../../components'; +import { SectionLoading } from '../../../shared_imports'; export class AutoFollowPatternAdd extends PureComponent { static propTypes = { @@ -44,30 +43,37 @@ export class AutoFollowPatternAdd extends PureComponent { } = this.props; return ( - - - } - /> - - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + - ); - } + + ); + } + + return ( + + + } + /> - return ( } /> - ); - }} - - + + ); + }} + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js index d060fb83832c6..fa97b28c8b472 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js @@ -5,12 +5,12 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPageContent, EuiSpacer } from '@elastic/eui'; +import { EuiButton, EuiPageContent, EuiEmptyPrompt, EuiPageContentBody } from '@elastic/eui'; import { listBreadcrumb, editBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; @@ -18,10 +18,9 @@ import { AutoFollowPatternForm, AutoFollowPatternPageTitle, RemoteClustersProvider, - SectionLoading, - SectionError, } from '../../components'; import { API_STATUS } from '../../constants'; +import { SectionLoading } from '../../../shared_imports'; export class AutoFollowPatternEdit extends PureComponent { static propTypes = { @@ -80,13 +79,6 @@ export class AutoFollowPatternEdit extends PureComponent { }, } = this.props; - const title = i18n.translate( - 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorTitle', - { - defaultMessage: 'Error loading auto-follow pattern', - } - ); - const errorMessage = error.body.statusCode === 404 ? { @@ -101,38 +93,42 @@ export class AutoFollowPatternEdit extends PureComponent { : error; return ( - - - - - - - - + + +

+ } + body={

{errorMessage}

} + actions={ + - -
-
- + + } + /> + ); } - renderLoadingAutoFollowPattern() { + renderLoading(loadingTitle) { return ( - - - + + {loadingTitle} + ); } @@ -145,55 +141,59 @@ export class AutoFollowPatternEdit extends PureComponent { match: { url: currentUrl }, } = this.props; + if (apiStatus.get === API_STATUS.LOADING || !autoFollowPattern) { + return this.renderLoading( + i18n.translate('xpack.crossClusterReplication.autoFollowPatternEditForm.loadingTitle', { + defaultMessage: 'Loading auto-follow pattern…', + }) + ); + } + + if (apiError.get) { + return this.renderGetAutoFollowPatternError(apiError.get); + } + return ( - - + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return this.renderLoading( + i18n.translate( + 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClustersMessage', + { defaultMessage: 'Loading remote clusters…' } + ) + ); } - /> - {apiStatus.get === API_STATUS.LOADING && this.renderLoadingAutoFollowPattern()} - - {apiError.get && this.renderGetAutoFollowPatternError(apiError.get)} - - {autoFollowPattern && ( - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - - - - ); - } + return ( + + + } + /> - return ( - - } - /> - ); - }} - - )} - + + } + /> + + ); + }} + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js index 836a4f5cc36fa..325c23641580c 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js @@ -8,16 +8,15 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { EuiPageContent } from '@elastic/eui'; +import { EuiPageContentBody, EuiPageContent } from '@elastic/eui'; import { setBreadcrumbs, listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; import { FollowerIndexForm, FollowerIndexPageTitle, RemoteClustersProvider, - SectionLoading, } from '../../components'; +import { SectionLoading } from '../../../shared_imports'; export class FollowerIndexAdd extends PureComponent { static propTypes = { @@ -45,30 +44,36 @@ export class FollowerIndexAdd extends PureComponent { } = this.props; return ( - - - } - /> - - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + - ); - } + + ); + } - return ( + return ( + + + } + /> } /> - ); - }} - - + + ); + }} + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js index 41b09a398b1f2..618d97f186516 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js @@ -5,18 +5,17 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiButtonEmpty, + EuiButton, EuiConfirmModal, - EuiFlexGroup, - EuiFlexItem, + EuiPageContentBody, EuiPageContent, - EuiSpacer, + EuiEmptyPrompt, } from '@elastic/eui'; import { setBreadcrumbs, listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; @@ -24,11 +23,10 @@ import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_rea import { FollowerIndexForm, FollowerIndexPageTitle, - SectionLoading, - SectionError, RemoteClustersProvider, } from '../../components'; import { API_STATUS } from '../../constants'; +import { SectionLoading } from '../../../shared_imports'; export class FollowerIndexEdit extends PureComponent { static propTypes = { @@ -104,14 +102,11 @@ export class FollowerIndexEdit extends PureComponent { closeConfirmModal = () => this.setState({ showConfirmModal: false }); - renderLoadingFollowerIndex() { + renderLoading(loadingTitle) { return ( - - - + + {loadingTitle} + ); } @@ -122,13 +117,6 @@ export class FollowerIndexEdit extends PureComponent { }, } = this.props; - const title = i18n.translate( - 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorTitle', - { - defaultMessage: 'Error loading follower index', - } - ); - const errorMessage = error.body.statusCode === 404 ? { @@ -143,27 +131,33 @@ export class FollowerIndexEdit extends PureComponent { : error; return ( - - - - - - - - + + +

+ } + body={

{errorMessage}

} + actions={ + - -
- - + + } + /> + ); } @@ -237,57 +231,63 @@ export class FollowerIndexEdit extends PureComponent { /* remove non-editable properties */ const { shards, ...rest } = followerIndex || {}; // eslint-disable-line no-unused-vars + if (apiStatus.get === API_STATUS.LOADING || !followerIndex) { + return this.renderLoading( + i18n.translate( + 'xpack.crossClusterReplication.followerIndexEditForm.loadingFollowerIndexTitle', + { defaultMessage: 'Loading follower index…' } + ) + ); + } + + if (apiError.get) { + return this.renderGetFollowerIndexError(apiError.get); + } + return ( - - + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return this.renderLoading( + i18n.translate( + 'xpack.crossClusterReplication.followerIndexEditForm.loadingRemoteClustersMessage', + { defaultMessage: 'Loading remote clusters…' } + ) + ); } - /> - {apiStatus.get === API_STATUS.LOADING && this.renderLoadingFollowerIndex()} - - {apiError.get && this.renderGetFollowerIndexError(apiError.get)} - {followerIndex && ( - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - - - - ); - } + return ( + + + } + /> - return ( - - } - /> - ); - }} - - )} + + } + /> - {showConfirmModal && this.renderConfirmModal()} - + {showConfirmModal && this.renderConfirmModal()} + + ); + }} + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js index 1885f33f9d633..1ab4e1a3e003a 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js @@ -5,24 +5,17 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButton, - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiSpacer, -} from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt, EuiText, EuiSpacer } from '@elastic/eui'; import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; -import { extractQueryParams } from '../../../../shared_imports'; +import { extractQueryParams, SectionLoading } from '../../../../shared_imports'; import { trackUiMetric, METRIC_TYPE } from '../../../services/track_ui_metric'; import { API_STATUS, UIM_AUTO_FOLLOW_PATTERN_LIST_LOAD } from '../../../constants'; -import { SectionLoading, SectionError, SectionUnauthorized } from '../../../components'; +import { SectionError, SectionUnauthorized } from '../../../components'; import { AutoFollowPatternTable, DetailPanel } from './components'; const REFRESH_RATE_MS = 30000; @@ -103,47 +96,77 @@ export class AutoFollowPatternList extends PureComponent { clearInterval(this.interval); } - renderHeader() { - const { isAuthorized, history } = this.props; + renderEmpty() { + return ( +
+ + + + } + body={ +

+ +

+ } + actions={ + + + + } + /> +
+ ); + } + + renderList() { + const { selectAutoFollowPattern, autoFollowPatterns } = this.props; + const { isDetailPanelOpen } = this.state; + return ( - - - - -

- -

-
-
+ <> + +

+ +

+
+ + - - {isAuthorized && ( - - - - )} - -
+ - -
+ {isDetailPanelOpen && ( + selectAutoFollowPattern(null)} /> + )} + ); } - renderContent(isEmpty) { - const { apiError, apiStatus, isAuthorized } = this.props; + render() { + const { autoFollowPatterns, apiError, apiStatus, isAuthorized } = this.props; + const isEmpty = apiStatus === API_STATUS.IDLE && !autoFollowPatterns.length; if (!isAuthorized) { return ( @@ -171,12 +194,7 @@ export class AutoFollowPatternList extends PureComponent { } ); - return ( - - - - - ); + return ; } if (isEmpty) { @@ -185,83 +203,17 @@ export class AutoFollowPatternList extends PureComponent { if (apiStatus === API_STATUS.LOADING) { return ( - - - +
+ + + +
); } return this.renderList(); } - - renderEmpty() { - return ( - - - - } - body={ - -

- -

-
- } - actions={ - - - - } - data-test-subj="emptyPrompt" - /> - ); - } - - renderList() { - const { selectAutoFollowPattern, autoFollowPatterns } = this.props; - - const { isDetailPanelOpen } = this.state; - - return ( - <> - - {isDetailPanelOpen && ( - selectAutoFollowPattern(null)} /> - )} - - ); - } - - render() { - const { autoFollowPatterns, apiStatus } = this.props; - const isEmpty = apiStatus === API_STATUS.IDLE && !autoFollowPatterns.length; - - return ( - - {!isEmpty && this.renderHeader()} - {this.renderContent(isEmpty)} - - ); - } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js index 87002c936179a..0d228f2e63802 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js @@ -8,13 +8,17 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiInMemoryTable, + EuiButton, EuiLink, EuiLoadingKibana, EuiOverlayMask, EuiHealth, } from '@elastic/eui'; +import { reactRouterNavigate } from '../../../../../../../../../../src/plugins/kibana_react/public'; import { API_STATUS, UIM_AUTO_FOLLOW_PATTERN_SHOW_DETAILS_CLICK } from '../../../../../constants'; import { AutoFollowPatternDeleteProvider, @@ -305,6 +309,19 @@ export class AutoFollowPatternTable extends PureComponent { )} /> ) : undefined, + toolsRight: ( + + + + ), onChange: this.onSearch, box: { incremental: true, diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js index 0d0943d870266..866afa3e6e6dc 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js @@ -97,7 +97,7 @@ export class ContextMenu extends PureComponent { anchorPosition={anchorPosition} repositionOnScroll > - + ) : undefined, + toolsRight: ( + + + + ), onChange: this.onSearch, box: { incremental: true, diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js index 743a9ec47e689..a52ba0e613ca9 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js @@ -5,24 +5,17 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButton, - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiSpacer, -} from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt, EuiText, EuiSpacer } from '@elastic/eui'; import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; -import { extractQueryParams } from '../../../../shared_imports'; +import { extractQueryParams, SectionLoading } from '../../../../shared_imports'; import { trackUiMetric, METRIC_TYPE } from '../../../services/track_ui_metric'; import { API_STATUS, UIM_FOLLOWER_INDEX_LIST_LOAD } from '../../../constants'; -import { SectionLoading, SectionError, SectionUnauthorized } from '../../../components'; +import { SectionError, SectionUnauthorized } from '../../../components'; import { FollowerIndicesTable, DetailPanel } from './components'; const REFRESH_RATE_MS = 30000; @@ -94,47 +87,87 @@ export class FollowerIndicesList extends PureComponent { clearInterval(this.interval); } - renderHeader() { - const { isAuthorized, history } = this.props; + renderEmpty() { + return ( +
+ + + + } + body={ +

+ +

+ } + actions={ + + + + } + /> +
+ ); + } + renderLoading() { return ( - - - - -

- -

-
-
+
+ + + +
+ ); + } + + renderList() { + const { selectFollowerIndex, followerIndices } = this.props; + + const { isDetailPanelOpen } = this.state; + + return ( + <> + +

+ +

+
- - {isAuthorized && ( - - - - )} - -
+ - -
+ + + {isDetailPanelOpen && selectFollowerIndex(null)} />} + ); } - renderContent(isEmpty) { - const { apiError, isAuthorized, apiStatus } = this.props; + render() { + const { followerIndices, apiError, isAuthorized, apiStatus } = this.props; + const isEmpty = apiStatus === API_STATUS.IDLE && !followerIndices.length; if (!isAuthorized) { return ( @@ -162,12 +195,7 @@ export class FollowerIndicesList extends PureComponent { } ); - return ( - - - - - ); + return ; } if (isEmpty) { @@ -180,79 +208,4 @@ export class FollowerIndicesList extends PureComponent { return this.renderList(); } - - renderEmpty() { - return ( - - - - } - body={ - -

- -

-
- } - actions={ - - - - } - data-test-subj="emptyPrompt" - /> - ); - } - - renderLoading() { - return ( - - - - ); - } - - renderList() { - const { selectFollowerIndex, followerIndices } = this.props; - - const { isDetailPanelOpen } = this.state; - - return ( - - - {isDetailPanelOpen && selectFollowerIndex(null)} />} - - ); - } - - render() { - const { followerIndices, apiStatus } = this.props; - const isEmpty = apiStatus === API_STATUS.IDLE && !followerIndices.length; - return ( - - {!isEmpty && this.renderHeader()} - {this.renderContent(isEmpty)} - - ); - } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js index ff37c2157d515..70d35dcb22569 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js @@ -9,7 +9,7 @@ import React, { PureComponent } from 'react'; import { Route, Switch } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui'; +import { EuiSpacer, EuiPageHeader } from '@elastic/eui'; import { setBreadcrumbs, listBreadcrumb } from '../../services/breadcrumbs'; import { routing } from '../../services/routing'; @@ -66,40 +66,33 @@ export class CrossClusterReplicationHome extends PureComponent { render() { return ( - - - -

+ <> + -

-
- - - - - {this.tabs.map((tab) => ( - this.onSectionChange(tab.id)} - isSelected={tab.id === this.state.activeSection} - key={tab.id} - data-test-subj={tab.testSubj} - > - {tab.name} - - ))} - + + } + tabs={this.tabs.map((tab) => ({ + onClick: () => this.onSectionChange(tab.id), + isSelected: tab.id === this.state.activeSection, + key: tab.id, + 'data-test-subj': tab.testSubj, + label: tab.name, + }))} + /> - + - - - - -
-
+ + + + + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts b/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts index fd28175318666..55a10749230c7 100644 --- a/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts +++ b/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts @@ -5,4 +5,9 @@ * 2.0. */ -export { extractQueryParams, indices } from '../../../../src/plugins/es_ui_shared/public'; +export { + extractQueryParams, + indices, + SectionLoading, + PageError, +} from '../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/data_enhanced/server/collectors/fetch.ts b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts index 6feb13432d07b..bfd5ee745cd75 100644 --- a/x-pack/plugins/data_enhanced/server/collectors/fetch.ts +++ b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { estypes } from '@elastic/elasticsearch'; import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; import { SearchResponse } from 'elasticsearch'; @@ -36,8 +36,12 @@ export function fetchProvider(config$: Observable, logger: L }, }); - // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregations - const buckets: SessionPersistedTermsBucket[] = esResponse.aggregations!.persisted.buckets; + const aggs = esResponse.aggregations as Record< + string, + estypes.AggregationsMultiBucketAggregate + >; + + const buckets = aggs.persisted.buckets; if (!buckets.length) { return { transientCount: 0, persistedCount: 0, totalCount: 0 }; } diff --git a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts index e228ba725489c..461c41b46491c 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts @@ -18,7 +18,7 @@ export async function getSearchStatus( ): Promise> { // TODO: Handle strategies other than the default one try { - // @ts-expect-error @elastic/elasticsearch status method is not defined + // @ts-expect-error start_time_in_millis: EpochMillis is string | number const apiResponse: ApiResponse = await client.asyncSearch.status({ id: asyncId, }); diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/licensing_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/licensing_logic.mock.ts index 2cea6061b63ab..f227928b45821 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/licensing_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/licensing_logic.mock.ts @@ -11,8 +11,11 @@ export const mockLicensingValues = { license: licensingMock.createLicense(), hasPlatinumLicense: false, hasGoldLicense: false, + isTrial: false, + canManageLicense: true, }; jest.mock('../../shared/licensing', () => ({ + ...(jest.requireActual('../../shared/licensing') as object), LicensingLogic: { values: mockLicensingValues }, })); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts index 23d638d5f25f3..b38659b7a9a79 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts @@ -6,7 +6,7 @@ */ import { EngineDetails } from '../components/engine/types'; -import { ENGINES_TITLE } from '../components/engines'; +import { ENGINES_TITLE } from '../components/engines/constants'; import { generateEncodedPath } from '../utils/encode_path_params'; export const mockEngineValues = { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts index 7b08e82a4cf20..f69e3492d26eb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts @@ -6,7 +6,11 @@ */ import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__'; -import { LogicMounter } from '../__mocks__/kea_logic'; +import { LogicMounter } from '../__mocks__/kea_logic/logic_mounter.test_helper'; + +jest.mock('../shared/licensing', () => ({ + LicensingLogic: { selectors: { hasPlatinumLicense: () => false } }, +})); import { AppLogic } from './app_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts index 44416b596e6ef..90b37e6a4d4ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts @@ -9,6 +9,8 @@ import { kea, MakeLogicType } from 'kea'; import { InitialAppData } from '../../../common/types'; +import { LicensingLogic } from '../shared/licensing'; + import { ConfiguredLimits, Account, Role } from './types'; import { getRoleAbilities } from './utils/role'; @@ -43,8 +45,8 @@ export const AppLogic = kea [selectors.account], - ({ role }) => (role ? getRoleAbilities(role) : {}), + (selectors) => [selectors.account, LicensingLogic.selectors.hasPlatinumLicense], + ({ role }, hasPlatinumLicense) => (role ? getRoleAbilities(role, hasPlatinumLicense) : {}), ], }, }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.test.tsx index 9832915f19e9e..280282a2fc6ec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.test.tsx @@ -8,18 +8,22 @@ import '../../../__mocks__/shallow_useeffect.mock'; import { mockKibanaValues, setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; import { mockUseParams } from '../../../__mocks__/react_router'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { Loading } from '../../../shared/loading'; -import { rerender } from '../../../test_helpers'; -import { LogRetentionCallout } from '../log_retention'; +import { + rerender, + getPageTitle, + getPageHeaderActions, + getPageHeaderChildren, +} from '../../../test_helpers'; +import { LogRetentionTooltip, LogRetentionCallout } from '../log_retention'; import { AnalyticsLayout } from './analytics_layout'; -import { AnalyticsHeader } from './components'; +import { AnalyticsFilters } from './components'; describe('AnalyticsLayout', () => { const { history } = mockKibanaValues; @@ -47,18 +51,20 @@ describe('AnalyticsLayout', () => { ); - expect(wrapper.find(FlashMessages)).toHaveLength(1); expect(wrapper.find(LogRetentionCallout)).toHaveLength(1); + expect(getPageHeaderActions(wrapper).find(LogRetentionTooltip)).toHaveLength(1); + expect(getPageHeaderChildren(wrapper).find(AnalyticsFilters)).toHaveLength(1); - expect(wrapper.find(AnalyticsHeader).prop('title')).toEqual('Hello'); + expect(getPageTitle(wrapper)).toEqual('Hello'); expect(wrapper.find('[data-test-subj="world"]').text()).toEqual('World!'); + + expect(wrapper.prop('pageChrome')).toEqual(['Engines', 'some-engine', 'Analytics']); }); - it('renders a loading component if data is not done loading', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); + it('passes analytics breadcrumbs', () => { + const wrapper = shallow(); - expect(wrapper.type()).toEqual(Loading); + expect(wrapper.prop('pageChrome')).toEqual(['Engines', 'some-engine', 'Analytics', 'Queries']); }); describe('data loading', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx index 91de4cc498988..0923f9497a8fe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx @@ -10,25 +10,27 @@ import { useParams } from 'react-router-dom'; import { useValues, useActions } from 'kea'; -import { EuiSpacer } from '@elastic/eui'; - -import { FlashMessages } from '../../../shared/flash_messages'; import { KibanaLogic } from '../../../shared/kibana'; -import { Loading } from '../../../shared/loading'; +import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; +import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; -import { LogRetentionCallout, LogRetentionOptions } from '../log_retention'; +import { LogRetentionTooltip, LogRetentionCallout, LogRetentionOptions } from '../log_retention'; -import { AnalyticsHeader } from './components'; +import { AnalyticsFilters } from './components'; +import { ANALYTICS_TITLE } from './constants'; import { AnalyticsLogic } from './'; interface Props { title: string; + breadcrumbs?: BreadcrumbTrail; isQueryView?: boolean; isAnalyticsView?: boolean; } export const AnalyticsLayout: React.FC = ({ title, + breadcrumbs = [], isQueryView, isAnalyticsView, children, @@ -43,15 +45,21 @@ export const AnalyticsLayout: React.FC = ({ if (isAnalyticsView) loadAnalyticsData(); }, [history.location.search]); - if (dataLoading) return ; - return ( - <> - - + , + ], + children: , + responsive: false, + }} + isLoading={dataLoading} + > {children} - - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx index 397f1f1e1e1c3..d56fe949431c3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { NotFound } from '../../../shared/not_found'; import { ENGINE_ANALYTICS_PATH, @@ -23,14 +22,7 @@ import { } from '../../routes'; import { generateEnginePath, getEngineBreadcrumbs } from '../engine'; -import { - ANALYTICS_TITLE, - TOP_QUERIES, - TOP_QUERIES_NO_RESULTS, - TOP_QUERIES_NO_CLICKS, - TOP_QUERIES_WITH_CLICKS, - RECENT_QUERIES, -} from './constants'; +import { ANALYTICS_TITLE } from './constants'; import { Analytics, TopQueries, @@ -42,42 +34,37 @@ import { } from './views'; export const AnalyticsRouter: React.FC = () => { - const ANALYTICS_BREADCRUMB = getEngineBreadcrumbs([ANALYTICS_TITLE]); - return ( - - - - - - - + - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.test.tsx similarity index 87% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.test.tsx index 5269ea9110065..7abb02110e2d9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.test.tsx @@ -12,15 +12,13 @@ import React, { ReactElement } from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import moment, { Moment } from 'moment'; -import { EuiPageHeader, EuiSelect, EuiDatePickerRange, EuiButton } from '@elastic/eui'; - -import { LogRetentionTooltip } from '../../log_retention'; +import { EuiSelect, EuiDatePickerRange, EuiButton } from '@elastic/eui'; import { DEFAULT_START_DATE, DEFAULT_END_DATE } from '../constants'; -import { AnalyticsHeader } from './'; +import { AnalyticsFilters } from './'; -describe('AnalyticsHeader', () => { +describe('AnalyticsFilters', () => { const { history } = mockKibanaValues; const values = { @@ -45,18 +43,14 @@ describe('AnalyticsHeader', () => { }); it('renders', () => { - wrapper = shallow(); - - expect(wrapper.type()).toEqual(EuiPageHeader); - expect(wrapper.find('h1').text()).toEqual('Hello world'); + wrapper = shallow(); - expect(wrapper.find(LogRetentionTooltip)).toHaveLength(1); expect(wrapper.find(EuiSelect)).toHaveLength(1); expect(wrapper.find(EuiDatePickerRange)).toHaveLength(1); }); it('renders tags & dates with default values when no search query params are present', () => { - wrapper = shallow(); + wrapper = shallow(); expect(getTagsSelect().prop('value')).toEqual(''); expect(getStartDatePicker().props.startDate._i).toEqual(DEFAULT_START_DATE); @@ -69,7 +63,7 @@ describe('AnalyticsHeader', () => { const allTags = [...values.allTags, 'tag1', 'tag2', 'tag3']; setMockValues({ ...values, allTags }); - wrapper = shallow(); + wrapper = shallow(); }); it('renders the tags select with currentTag value and allTags options', () => { @@ -95,7 +89,7 @@ describe('AnalyticsHeader', () => { beforeEach(() => { history.location.search = '?start=1970-01-01&end=1970-01-02'; - wrapper = shallow(); + wrapper = shallow(); }); it('renders the start date picker', () => { @@ -127,7 +121,7 @@ describe('AnalyticsHeader', () => { beforeEach(() => { history.location.search = '?start=1970-01-02&end=1970-01-01'; - wrapper = shallow(); + wrapper = shallow(); }); it('renders the date pickers as invalid', () => { @@ -148,7 +142,7 @@ describe('AnalyticsHeader', () => { }; beforeEach(() => { - wrapper = shallow(); + wrapper = shallow(); }); it('pushes up new tag & date state to the search query', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.tsx new file mode 100644 index 0000000000000..0c8455e986ae1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; + +import { useValues } from 'kea'; +import moment from 'moment'; +import queryString from 'query-string'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiSelect, + EuiDatePickerRange, + EuiDatePicker, + EuiButton, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AnalyticsLogic } from '../'; +import { KibanaLogic } from '../../../../shared/kibana'; + +import { DEFAULT_START_DATE, DEFAULT_END_DATE, SERVER_DATE_FORMAT } from '../constants'; +import { convertTagsToSelectOptions } from '../utils'; + +export const AnalyticsFilters: React.FC = () => { + const { allTags } = useValues(AnalyticsLogic); + const { history } = useValues(KibanaLogic); + + // Parse out existing filters from URL query string + const { start, end, tag } = queryString.parse(history.location.search); + const [startDate, setStartDate] = useState( + start ? moment(start, SERVER_DATE_FORMAT) : moment(DEFAULT_START_DATE) + ); + const [endDate, setEndDate] = useState( + end ? moment(end, SERVER_DATE_FORMAT) : moment(DEFAULT_END_DATE) + ); + const [currentTag, setCurrentTag] = useState((tag as string) || ''); + + // Set the current URL query string on filter + const onApplyFilters = () => { + const search = queryString.stringify({ + start: moment(startDate).format(SERVER_DATE_FORMAT), + end: moment(endDate).format(SERVER_DATE_FORMAT), + tag: currentTag || undefined, + }); + history.push({ search }); + }; + + const hasInvalidDateRange = startDate > endDate; + + return ( + + + setCurrentTag(e.target.value)} + aria-label={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.tagAriaLabel', + { defaultMessage: 'Filter by analytics tag"' } + )} + fullWidth + /> + + + date && setStartDate(date)} + startDate={startDate} + endDate={endDate} + isInvalid={hasInvalidDateRange} + aria-label={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.startDateAriaLabel', + { defaultMessage: 'Filter by start date' } + )} + /> + } + endDateControl={ + date && setEndDate(date)} + startDate={startDate} + endDate={endDate} + isInvalid={hasInvalidDateRange} + aria-label={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.endDateAriaLabel', + { defaultMessage: 'Filter by end date' } + )} + /> + } + fullWidth + /> + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.applyButtonLabel', + { defaultMessage: 'Apply filters' } + )} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx deleted file mode 100644 index 8a87a5e8c211c..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState } from 'react'; - -import { useValues } from 'kea'; -import moment from 'moment'; -import queryString from 'query-string'; - -import { - EuiPageHeader, - EuiPageHeaderSection, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiSelect, - EuiDatePickerRange, - EuiDatePicker, - EuiButton, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { AnalyticsLogic } from '../'; -import { KibanaLogic } from '../../../../shared/kibana'; -import { LogRetentionTooltip, LogRetentionOptions } from '../../log_retention'; - -import { DEFAULT_START_DATE, DEFAULT_END_DATE, SERVER_DATE_FORMAT } from '../constants'; -import { convertTagsToSelectOptions } from '../utils'; - -import './analytics_header.scss'; - -interface Props { - title: string; -} -export const AnalyticsHeader: React.FC = ({ title }) => { - const { allTags } = useValues(AnalyticsLogic); - const { history } = useValues(KibanaLogic); - - // Parse out existing filters from URL query string - const { start, end, tag } = queryString.parse(history.location.search); - const [startDate, setStartDate] = useState( - start ? moment(start, SERVER_DATE_FORMAT) : moment(DEFAULT_START_DATE) - ); - const [endDate, setEndDate] = useState( - end ? moment(end, SERVER_DATE_FORMAT) : moment(DEFAULT_END_DATE) - ); - const [currentTag, setCurrentTag] = useState((tag as string) || ''); - - // Set the current URL query string on filter - const onApplyFilters = () => { - const search = queryString.stringify({ - start: moment(startDate).format(SERVER_DATE_FORMAT), - end: moment(endDate).format(SERVER_DATE_FORMAT), - tag: currentTag || undefined, - }); - history.push({ search }); - }; - - const hasInvalidDateRange = startDate > endDate; - - return ( - - - - - -

{title}

-
-
- - - -
-
- - - - setCurrentTag(e.target.value)} - aria-label={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.tagAriaLabel', - { defaultMessage: 'Filter by analytics tag"' } - )} - fullWidth - /> - - - date && setStartDate(date)} - startDate={startDate} - endDate={endDate} - isInvalid={hasInvalidDateRange} - aria-label={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.startDateAriaLabel', - { defaultMessage: 'Filter by start date' } - )} - /> - } - endDateControl={ - date && setEndDate(date)} - startDate={startDate} - endDate={endDate} - isInvalid={hasInvalidDateRange} - aria-label={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.endDateAriaLabel', - { defaultMessage: 'Filter by end date' } - )} - /> - } - fullWidth - /> - - - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.applyButtonLabel', - { defaultMessage: 'Apply filters' } - )} - - - - -
- ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts index de5c8209d2347..5309681b80d6d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts @@ -7,7 +7,7 @@ export { AnalyticsCards } from './analytics_cards'; export { AnalyticsChart } from './analytics_chart'; -export { AnalyticsHeader } from './analytics_header'; +export { AnalyticsFilters } from './analytics_filters'; export { AnalyticsSection } from './analytics_section'; export { AnalyticsSearch } from './analytics_search'; export { AnalyticsTable, RecentQueriesTable, QueryClicksTable } from './analytics_tables'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx index a942918fa9c62..f3fee2553d2fd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx @@ -12,16 +12,12 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; - import { AnalyticsLayout } from '../analytics_layout'; import { AnalyticsCards, AnalyticsChart, QueryClicksTable } from '../components'; import { QueryDetail } from './'; describe('QueryDetail', () => { - const mockBreadcrumbs = ['Engines', 'some-engine', 'Analytics']; - beforeEach(() => { mockUseParams.mockReturnValue({ query: 'some-query' }); @@ -32,16 +28,10 @@ describe('QueryDetail', () => { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(AnalyticsLayout).prop('title')).toEqual('"some-query"'); - expect(wrapper.find(SetPageChrome).prop('trail')).toEqual([ - 'Engines', - 'some-engine', - 'Analytics', - 'Query', - 'some-query', - ]); + expect(wrapper.find(AnalyticsLayout).prop('breadcrumbs')).toEqual(['Query', 'some-query']); expect(wrapper.find(AnalyticsCards)).toHaveLength(1); expect(wrapper.find(AnalyticsChart)).toHaveLength(1); @@ -50,7 +40,7 @@ describe('QueryDetail', () => { it('renders empty "" search titles correctly', () => { mockUseParams.mockReturnValue({ query: '""' }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(AnalyticsLayout).prop('title')).toEqual('""'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx index 83c83aa36f1bb..e68984459cf10 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx @@ -12,8 +12,6 @@ import { useValues } from 'kea'; import { EuiPanel, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; -import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs'; import { useDecodedParams } from '../../../utils/encode_path_params'; import { AnalyticsLayout } from '../analytics_layout'; @@ -25,10 +23,7 @@ const QUERY_DETAIL_TITLE = i18n.translate( { defaultMessage: 'Query' } ); -interface Props { - breadcrumbs: BreadcrumbTrail; -} -export const QueryDetail: React.FC = ({ breadcrumbs }) => { +export const QueryDetail: React.FC = () => { const { query } = useDecodedParams(); const queryTitle = query === '""' ? query : `"${query}"`; @@ -37,9 +32,7 @@ export const QueryDetail: React.FC = ({ breadcrumbs }) => { ); return ( - - - + { const { recentQueries } = useValues(AnalyticsLogic); return ( - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx index 6459126560b3a..81b3d08770be6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx @@ -18,7 +18,7 @@ export const TopQueries: React.FC = () => { const { topQueries } = useValues(AnalyticsLogic); return ( - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx index 8e2591697feaa..2aec88bd372fe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx @@ -18,7 +18,11 @@ export const TopQueriesNoClicks: React.FC = () => { const { topQueriesNoClicks } = useValues(AnalyticsLogic); return ( - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx index e093a5130d204..835b259330c83 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx @@ -18,7 +18,11 @@ export const TopQueriesNoResults: React.FC = () => { const { topQueriesNoResults } = useValues(AnalyticsLogic); return ( - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx index 87e276a8382c3..9bea265df55ae 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx @@ -18,7 +18,11 @@ export const TopQueriesWithClicks: React.FC = () => { const { topQueriesWithClicks } = useValues(AnalyticsLogic); return ( - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.test.tsx index 286658c011002..737908752911d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.test.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiCopy, EuiLoadingContent, EuiPageContentBody } from '@elastic/eui'; +import { EuiCopy, EuiLoadingContent } from '@elastic/eui'; import { DEFAULT_META } from '../../../shared/constants'; import { externalUrl } from '../../../shared/enterprise_search_url'; @@ -20,6 +20,7 @@ import { externalUrl } from '../../../shared/enterprise_search_url'; import { Credentials } from './credentials'; import { CredentialsFlyout } from './credentials_flyout'; +import { CredentialsList } from './credentials_list'; describe('Credentials', () => { // Kea mocks @@ -42,7 +43,7 @@ describe('Credentials', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiPageContentBody)).toHaveLength(1); + expect(wrapper.find(CredentialsList)).toHaveLength(1); }); it('fetches data on mount', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx index 8918445982ea6..f81d8d64737df 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx @@ -10,9 +10,7 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; import { - EuiPageHeader, EuiTitle, - EuiPageContentBody, EuiPanel, EuiCopy, EuiButtonIcon, @@ -25,8 +23,7 @@ import { import { i18n } from '@kbn/i18n'; import { externalUrl } from '../../../shared/enterprise_search_url/external_url'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { AppSearchPageTemplate } from '../layout'; import { CREDENTIALS_TITLE } from './constants'; import { CredentialsFlyout } from './credentials_flyout'; @@ -52,74 +49,72 @@ export const Credentials: React.FC = () => { }, []); return ( - <> - - - - {shouldShowCredentialsForm && } - - + + {shouldShowCredentialsForm && } + + +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.apiEndpoint', { + defaultMessage: 'Endpoint', + })} +

+
+ + {(copy) => ( + <> + + {externalUrl.enterpriseSearchUrl} + + )} + +
+ + + +

- {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.apiEndpoint', { - defaultMessage: 'Endpoint', + {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.apiKeys', { + defaultMessage: 'API Keys', })}

- - {(copy) => ( - <> - - {externalUrl.enterpriseSearchUrl} - - )} - -
- - - - -

- {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.apiKeys', { - defaultMessage: 'API Keys', - })} -

-
-
- - {!dataLoading && ( - showCredentialsForm()} - > - {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.createKey', { - defaultMessage: 'Create a key', - })} - - )} - -
- - - - {!!dataLoading ? : } - -
- + + + {!dataLoading && ( + showCredentialsForm()} + > + {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.createKey', { + defaultMessage: 'Create a key', + })} + + )} + + + + + {!!dataLoading ? : } + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.test.tsx index 8034b72d885da..04f05349217c0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiIcon, EuiButton } from '@elastic/eui'; +import { EuiIcon, EuiButton, EuiTitle, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; import { LoadingOverlay } from '../../../shared/loading'; @@ -27,6 +27,16 @@ describe('DataPanel', () => { expect(wrapper.find('[data-test-subj="children"]').text()).toEqual('Look at this graph'); }); + it('conditionally renders a spacer between the header and children', () => { + const wrapper = shallow(Test} />); + + expect(wrapper.find(EuiSpacer)).toHaveLength(0); + + wrapper.setProps({ children: 'hello world' }); + + expect(wrapper.find(EuiSpacer)).toHaveLength(1); + }); + describe('components', () => { it('renders with an icon', () => { const wrapper = shallow(The Smoke Monster} iconType="eye" />); @@ -70,6 +80,26 @@ describe('DataPanel', () => { }); describe('props', () => { + it('passes titleSize to the title', () => { + const wrapper = shallow(Test} />); + + expect(wrapper.find(EuiTitle).prop('size')).toEqual('xs'); // Default + + wrapper.setProps({ titleSize: 's' }); + + expect(wrapper.find(EuiTitle).prop('size')).toEqual('s'); + }); + + it('passes responsive to the header flex group', () => { + const wrapper = shallow(Test} />); + + expect(wrapper.find(EuiFlexGroup).first().prop('responsive')).toEqual(false); + + wrapper.setProps({ responsive: true }); + + expect(wrapper.find(EuiFlexGroup).first().prop('responsive')).toEqual(true); + }); + it('renders panel color based on filled flag', () => { const wrapper = shallow(Test} />); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.tsx index ce878dc3cf29a..4b22fbc93d411 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.tsx @@ -13,10 +13,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiIconProps, EuiPanel, EuiSpacer, EuiText, EuiTitle, + EuiTitleProps, } from '@elastic/eui'; import { LoadingOverlay } from '../../../shared/loading'; @@ -25,9 +27,11 @@ import './data_panel.scss'; interface Props { title: React.ReactElement; // e.g., h2 tag - subtitle?: string; - iconType?: string; + titleSize?: EuiTitleProps['size']; + subtitle?: React.ReactNode; + iconType?: EuiIconProps['type']; action?: React.ReactNode; + responsive?: boolean; filled?: boolean; hasBorder?: boolean; isLoading?: boolean; @@ -36,9 +40,11 @@ interface Props { export const DataPanel: React.FC = ({ title, + titleSize = 'xs', subtitle, iconType, action, + responsive = false, filled, hasBorder, isLoading, @@ -59,7 +65,7 @@ export const DataPanel: React.FC = ({ hasShadow={false} aria-busy={isLoading} > - + {iconType && ( @@ -68,7 +74,7 @@ export const DataPanel: React.FC = ({ )} - {title} + {title} {subtitle && ( @@ -79,8 +85,12 @@ export const DataPanel: React.FC = ({ {action && {action}} - - {children} + {children && ( + <> + + {children} + + )} {isLoading && } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx index 6d3caca87dcc3..7ed9b9ea65025 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx @@ -60,6 +60,7 @@ export const DocumentCreationButtons: React.FC = ({ disabled = false }) = = ({ disabled = false }) = = ({ disabled = false }) = = ({ disabled = false }) = ( - - - {i18n.translate('xpack.enterpriseSearch.appSearch.documents.empty.title', { - defaultMessage: 'Add your first documents', - })} - - } - body={ -

- {i18n.translate('xpack.enterpriseSearch.appSearch.documents.empty.description', { - defaultMessage: - 'You can index documents using the App Search Web Crawler, by uploading JSON, or by using the API.', - })} -

- } - actions={ - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.documents.empty.buttonLabel', { - defaultMessage: 'Read the documents guide', - })} - - } - /> -
+ + {i18n.translate('xpack.enterpriseSearch.appSearch.documents.empty.title', { + defaultMessage: 'Add your first documents', + })} + + } + body={ +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.documents.empty.description', { + defaultMessage: + 'You can index documents using the App Search Web Crawler, by uploading JSON, or by using the API.', + })} +

+ } + actions={ + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.documents.empty.buttonLabel', { + defaultMessage: 'Read the documents guide', + })} + + } + /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx index 4aade8e61b085..90da5bebe6d23 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx @@ -14,9 +14,10 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageHeader, EuiPageContent, EuiBasicTable } from '@elastic/eui'; +import { EuiPanel, EuiBasicTable } from '@elastic/eui'; + +import { getPageHeaderActions } from '../../../test_helpers'; -import { Loading } from '../../../shared/loading'; import { ResultFieldValue } from '../result'; import { DocumentDetail } from '.'; @@ -45,7 +46,7 @@ describe('DocumentDetail', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiPageContent).length).toBe(1); + expect(wrapper.find(EuiPanel).length).toBe(1); }); it('initializes data on mount', () => { @@ -59,17 +60,6 @@ describe('DocumentDetail', () => { expect(actions.setFields).toHaveBeenCalledWith([]); }); - it('will show a loader while data is loading', () => { - setMockValues({ - ...values, - dataLoading: true, - }); - - const wrapper = shallow(); - - expect(wrapper.find(Loading).length).toBe(1); - }); - describe('field values list', () => { let columns: any; @@ -102,8 +92,7 @@ describe('DocumentDetail', () => { it('will delete the document when the delete button is pressed', () => { const wrapper = shallow(); - const header = wrapper.find(EuiPageHeader).dive().children().dive(); - const button = header.find('[data-test-subj="DeleteDocumentButton"]'); + const button = getPageHeaderActions(wrapper).find('[data-test-subj="DeleteDocumentButton"]'); button.simulate('click'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx index 314c3529cf4db..175fb1239d380 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx @@ -10,22 +10,13 @@ import { useParams } from 'react-router-dom'; import { useActions, useValues } from 'kea'; -import { - EuiButton, - EuiPageHeader, - EuiPageContentBody, - EuiPageContent, - EuiBasicTable, - EuiBasicTableColumn, -} from '@elastic/eui'; +import { EuiPanel, EuiButton, EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DELETE_BUTTON_LABEL } from '../../../shared/constants'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; import { useDecodedParams } from '../../utils/encode_path_params'; import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { ResultFieldValue } from '../result'; import { DOCUMENTS_TITLE } from './constants'; @@ -52,10 +43,6 @@ export const DocumentDetail: React.FC = () => { }; }, []); - if (dataLoading) { - return ; - } - const columns: Array> = [ { name: i18n.translate('xpack.enterpriseSearch.appSearch.documentDetail.fieldHeader', { @@ -74,11 +61,11 @@ export const DocumentDetail: React.FC = () => { ]; return ( - <> - - { > {DELETE_BUTTON_LABEL} , - ]} - /> - - - - - - - + ], + }} + isLoading={dataLoading} + > + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx index 143ad3f55ff2f..b5b6dd453c9df 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx @@ -10,9 +10,9 @@ import '../../__mocks__/engine_logic.mock'; import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; +import { getPageHeaderActions } from '../../../test_helpers'; import { DocumentCreationButton } from './components'; import { SearchExperience } from './search_experience'; @@ -22,6 +22,7 @@ import { Documents } from '.'; describe('Documents', () => { const values = { isMetaEngine: false, + engine: { document_count: 1 }, myRole: { canManageEngineDocuments: true }, }; @@ -36,9 +37,6 @@ describe('Documents', () => { }); describe('DocumentCreationButton', () => { - const getHeader = (wrapper: ShallowWrapper) => - wrapper.find(EuiPageHeader).dive().children().dive(); - it('renders a DocumentCreationButton if the user can manage engine documents', () => { setMockValues({ ...values, @@ -46,7 +44,7 @@ describe('Documents', () => { }); const wrapper = shallow(); - expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(true); + expect(getPageHeaderActions(wrapper).find(DocumentCreationButton).exists()).toBe(true); }); it('does not render a DocumentCreationButton if the user cannot manage engine documents', () => { @@ -56,7 +54,7 @@ describe('Documents', () => { }); const wrapper = shallow(); - expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); + expect(getPageHeaderActions(wrapper).find(DocumentCreationButton).exists()).toBe(false); }); it('does not render a DocumentCreationButton for meta engines even if the user can manage engine documents', () => { @@ -67,7 +65,7 @@ describe('Documents', () => { }); const wrapper = shallow(); - expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); + expect(getPageHeaderActions(wrapper).find(DocumentCreationButton).exists()).toBe(false); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx index b4122a715f927..62c7759757bda 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx @@ -9,35 +9,32 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiPageHeader, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; - import { AppLogic } from '../../app_logic'; import { EngineLogic, getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; -import { DocumentCreationButton } from './components'; +import { DocumentCreationButton, EmptyState } from './components'; import { DOCUMENTS_TITLE } from './constants'; import { SearchExperience } from './search_experience'; export const Documents: React.FC = () => { - const { isMetaEngine } = useValues(EngineLogic); + const { isMetaEngine, engine } = useValues(EngineLogic); const { myRole } = useValues(AppLogic); return ( - <> - - ] - : undefined - } - /> - + ] : [], + }} + isEmptyState={!engine.document_count} + emptyState={} + > {isMetaEngine && ( <> { )} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss index d2e0a8155fa55..34aac402fbb39 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss @@ -15,6 +15,7 @@ .documentsSearchExperience__content { flex-grow: 4; + position: relative; } .documentsSearchExperience__pagingInfo { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx index a4d1a92ee45a4..3e8a9c1ab307c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx @@ -20,8 +20,6 @@ jest.mock('../../../../shared/use_local_storage', () => ({ })); import { useLocalStorage } from '../../../../shared/use_local_storage'; -import { EmptyState } from '../components'; - import { CustomizationCallout } from './customization_callout'; import { CustomizationModal } from './customization_modal'; import { SearchExperienceContent } from './search_experience_content'; @@ -58,14 +56,6 @@ describe('SearchExperience', () => { expect(wrapper.find(SearchExperienceContent)).toHaveLength(1); }); - it('renders an empty state when the engine does not have documents', () => { - setMockValues({ ...values, engine: { ...values.engine, document_count: 0 } }); - const wrapper = shallow(); - - expect(wrapper.find(EmptyState)).toHaveLength(1); - expect(wrapper.find(SearchExperienceContent)).toHaveLength(0); - }); - describe('when there are no selected filter fields', () => { let wrapper: ShallowWrapper; beforeEach(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx index 22029956601a6..709dfc69905f0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx @@ -21,7 +21,6 @@ import './search_experience.scss'; import { externalUrl } from '../../../../shared/enterprise_search_url'; import { useLocalStorage } from '../../../../shared/use_local_storage'; import { EngineLogic } from '../../engine'; -import { EmptyState } from '../components'; import { buildSearchUIConfig } from './build_search_ui_config'; import { buildSortOptions } from './build_sort_options'; @@ -141,11 +140,7 @@ export const SearchExperience: React.FC = () => { )}
- {engine.document_count && engine.document_count > 0 ? ( - - ) : ( - - )} + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx index 44a6da51ec8d6..e573502d76b9f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx @@ -15,6 +15,7 @@ import { shallow } from 'enzyme'; // @ts-expect-error types are not available for this package yet import { Results } from '@elastic/react-search-ui'; +import { Loading } from '../../../../shared/loading'; import { SchemaType } from '../../../../shared/schema/types'; import { Pagination } from './pagination'; @@ -82,13 +83,13 @@ describe('SearchExperienceContent', () => { expect(wrapper.find(Pagination).exists()).toBe(true); }); - it('renders empty if a search was not performed yet', () => { + it('renders a loading state if a search was not performed yet', () => { setMockSearchContextState({ ...searchState, wasSearched: false, }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(true); + expect(wrapper.find(Loading)).toHaveLength(1); }); it('renders results if a search was performed and there are more than 0 totalResults', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx index 84fe721f9eb7f..2322bcde831eb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx @@ -14,6 +14,7 @@ import { EuiFlexGroup, EuiSpacer, EuiEmptyPrompt } from '@elastic/eui'; import { Results, Paging, ResultsPerPage } from '@elastic/react-search-ui'; import { i18n } from '@kbn/i18n'; +import { Loading } from '../../../../shared/loading'; import { EngineLogic } from '../../engine'; import { Result } from '../../result/types'; @@ -26,7 +27,7 @@ export const SearchExperienceContent: React.FC = () => { const { isMetaEngine, engine } = useValues(EngineLogic); - if (!wasSearched) return null; + if (!wasSearched) return ; if (totalResults) { return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.scss index c750f63dab248..486abeb3dce4c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.scss @@ -6,21 +6,17 @@ */ .appSearchNavEngineLabel { - padding-top: $euiSizeS; + margin-left: $euiSizeS; + padding-top: $euiSizeXS; padding-bottom: $euiSizeS; - .euiText { - font-weight: $euiFontWeightMedium; - } .euiBadge { margin-top: $euiSizeXS; } } -.appSearchNavIcons { - // EUI override - &.euiFlexItem { - flex-grow: 0; - flex-direction: row; - } +.appSearchNavIcon { + // EuiSideNav renders icons to the left of the nav link by default, but we use icons + // as warning or error indicators & prefer to render them on the right side of the nav + order: 1; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx index c2b0a6a50fd06..015fb997c29ed 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx @@ -6,8 +6,14 @@ */ import { setMockValues } from '../../../__mocks__/kea_logic'; +import { mockUseRouteMatch } from '../../../__mocks__/react_router'; import { mockEngineValues } from '../../__mocks__'; +jest.mock('../../../shared/layout', () => ({ + ...jest.requireActual('../../../shared/layout'), // TODO: Remove once side nav components are gone + generateNavLink: jest.fn(({ to }) => ({ href: to })), +})); + import React from 'react'; import { shallow } from 'enzyme'; @@ -16,7 +22,305 @@ import { EuiBadge, EuiIcon } from '@elastic/eui'; import { rerender } from '../../../test_helpers'; -import { EngineNav } from './engine_nav'; +import { useEngineNav, EngineNav } from './engine_nav'; + +describe('useEngineNav', () => { + const values = { ...mockEngineValues, myRole: {}, dataLoading: false }; + + beforeEach(() => { + setMockValues(values); + mockUseRouteMatch.mockReturnValue(true); + }); + + describe('returns empty', () => { + it('does not return engine nav items if not on an engine route', () => { + mockUseRouteMatch.mockReturnValueOnce(false); + expect(useEngineNav()).toBeUndefined(); + }); + + it('does not return engine nav items if data is still loading', () => { + setMockValues({ ...values, dataLoading: true }); + expect(useEngineNav()).toBeUndefined(); + }); + + it('does not return engine nav items if engine data is missing', () => { + setMockValues({ ...values, engineName: '' }); + expect(useEngineNav()).toBeUndefined(); + }); + }); + + describe('returns an array of EUI side nav items', () => { + const BASE_NAV = [ + { + id: 'engineName', + name: 'some-engine', + renderItem: expect.any(Function), + 'data-test-subj': 'EngineLabel', + }, + { + id: 'overview', + name: 'Overview', + href: '/engines/some-engine', + 'data-test-subj': 'EngineOverviewLink', + }, + ]; + + it('always returns an engine label and overview link', () => { + expect(useEngineNav()).toEqual(BASE_NAV); + }); + + describe('engine label', () => { + const renderEngineLabel = (engineNav: any) => { + return shallow(engineNav[0].renderItem() as any); + }; + + it('renders the capitalized engine name', () => { + const wrapper = renderEngineLabel(useEngineNav()); + const name = wrapper.find('.eui-textTruncate'); + + expect(name.text()).toEqual('SOME-ENGINE'); + expect(wrapper.find(EuiBadge)).toHaveLength(0); + }); + + it('renders a sample engine badge for the sample engine', () => { + setMockValues({ ...values, isSampleEngine: true }); + const wrapper = renderEngineLabel(useEngineNav()); + + expect(wrapper.find(EuiBadge).prop('children')).toEqual('SAMPLE ENGINE'); + }); + + it('renders a meta engine badge for meta engines', () => { + setMockValues({ ...values, isMetaEngine: true }); + const wrapper = renderEngineLabel(useEngineNav()); + + expect(wrapper.find(EuiBadge).prop('children')).toEqual('META ENGINE'); + }); + }); + + it('returns an analytics nav item', () => { + setMockValues({ ...values, myRole: { canViewEngineAnalytics: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'analytics', + name: 'Analytics', + href: '/engines/some-engine/analytics', + 'data-test-subj': 'EngineAnalyticsLink', + }, + ]); + }); + + it('returns a documents nav item', () => { + setMockValues({ ...values, myRole: { canViewEngineDocuments: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'documents', + name: 'Documents', + href: '/engines/some-engine/documents', + 'data-test-subj': 'EngineDocumentsLink', + }, + ]); + }); + + it('returns a schema nav item', () => { + setMockValues({ ...values, myRole: { canViewEngineSchema: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'schema', + name: 'Schema', + href: '/engines/some-engine/schema', + 'data-test-subj': 'EngineSchemaLink', + icon: expect.anything(), + }, + ]); + }); + + describe('schema nav icons', () => { + const myRole = { canViewEngineSchema: true }; + + const renderIcons = (engineNav: any) => { + return shallow(
{engineNav[2].icon}
); + }; + + it('renders schema errors alert icon', () => { + setMockValues({ ...values, myRole, hasSchemaErrors: true }); + const wrapper = renderIcons(useEngineNav()); + + expect(wrapper.find('[data-test-subj="EngineNavSchemaErrors"]')).toHaveLength(1); + }); + + it('renders unconfirmed schema fields info icon', () => { + setMockValues({ ...values, myRole, hasUnconfirmedSchemaFields: true }); + const wrapper = renderIcons(useEngineNav()); + + expect(wrapper.find('[data-test-subj="EngineNavSchemaUnconfirmedFields"]')).toHaveLength(1); + }); + + it('renders schema conflicts alert icon', () => { + setMockValues({ ...values, myRole, hasSchemaConflicts: true }); + const wrapper = renderIcons(useEngineNav()); + + expect(wrapper.find('[data-test-subj="EngineNavSchemaConflicts"]')).toHaveLength(1); + }); + }); + + describe('crawler', () => { + const myRole = { canViewEngineCrawler: true }; + + it('returns a crawler nav item', () => { + setMockValues({ ...values, myRole }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'crawler', + name: 'Web Crawler', + href: '/engines/some-engine/crawler', + 'data-test-subj': 'EngineCrawlerLink', + }, + ]); + }); + + it('does not return a crawler nav item for meta engines', () => { + setMockValues({ ...values, myRole, isMetaEngine: true }); + expect(useEngineNav()).toEqual(BASE_NAV); + }); + }); + + describe('meta engine source engines', () => { + const myRole = { canViewMetaEngineSourceEngines: true }; + + it('returns a source engines nav item', () => { + setMockValues({ ...values, myRole, isMetaEngine: true }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'sourceEngines', + name: 'Engines', + href: '/engines/some-engine/engines', + 'data-test-subj': 'MetaEngineEnginesLink', + }, + ]); + }); + + it('does not return a source engines nav item for non-meta engines', () => { + setMockValues({ ...values, myRole, isMetaEngine: false }); + expect(useEngineNav()).toEqual(BASE_NAV); + }); + }); + + it('returns a relevance tuning nav item', () => { + setMockValues({ ...values, myRole: { canManageEngineRelevanceTuning: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'relevanceTuning', + name: 'Relevance Tuning', + href: '/engines/some-engine/relevance_tuning', + 'data-test-subj': 'EngineRelevanceTuningLink', + icon: expect.anything(), + }, + ]); + }); + + describe('relevance tuning nav icons', () => { + const myRole = { canManageEngineRelevanceTuning: true }; + + const renderIcons = (engineNav: any) => { + return shallow(
{engineNav[2].icon}
); + }; + + it('renders unconfirmed schema fields info icon', () => { + setMockValues({ ...values, myRole, engine: { unsearchedUnconfirmedFields: true } }); + const wrapper = renderIcons(useEngineNav()); + expect( + wrapper.find('[data-test-subj="EngineNavRelevanceTuningUnsearchedFields"]') + ).toHaveLength(1); + }); + + it('renders schema conflicts alert icon', () => { + setMockValues({ ...values, myRole, engine: { invalidBoosts: true } }); + const wrapper = renderIcons(useEngineNav()); + expect( + wrapper.find('[data-test-subj="EngineNavRelevanceTuningInvalidBoosts"]') + ).toHaveLength(1); + }); + + it('can render multiple icons', () => { + const engine = { invalidBoosts: true, unsearchedUnconfirmedFields: true }; + setMockValues({ ...values, myRole, engine }); + const wrapper = renderIcons(useEngineNav()); + expect(wrapper.find(EuiIcon)).toHaveLength(2); + }); + }); + + it('returns a synonyms nav item', () => { + setMockValues({ ...values, myRole: { canManageEngineSynonyms: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'synonyms', + name: 'Synonyms', + href: '/engines/some-engine/synonyms', + 'data-test-subj': 'EngineSynonymsLink', + }, + ]); + }); + + it('returns a curations nav item', () => { + setMockValues({ ...values, myRole: { canManageEngineCurations: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'curations', + name: 'Curations', + href: '/engines/some-engine/curations', + 'data-test-subj': 'EngineCurationsLink', + }, + ]); + }); + + it('returns a results settings nav item', () => { + setMockValues({ ...values, myRole: { canManageEngineResultSettings: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'resultSettings', + name: 'Result Settings', + href: '/engines/some-engine/result_settings', + 'data-test-subj': 'EngineResultSettingsLink', + }, + ]); + }); + + it('returns a Search UI nav item', () => { + setMockValues({ ...values, myRole: { canManageEngineSearchUi: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'searchUI', + name: 'Search UI', + href: '/engines/some-engine/search_ui', + 'data-test-subj': 'EngineSearchUILink', + }, + ]); + }); + + it('returns an API logs nav item', () => { + setMockValues({ ...values, myRole: { canViewEngineApiLogs: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'apiLogs', + name: 'API Logs', + href: '/engines/some-engine/api_logs', + 'data-test-subj': 'EngineAPILogsLink', + }, + ]); + }); + }); +}); describe('EngineNav', () => { const values = { ...mockEngineValues, myRole: {}, dataLoading: false }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx index 0edf01bada938..76e751cf4da5f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx @@ -6,13 +6,21 @@ */ import React from 'react'; +import { useRouteMatch } from 'react-router-dom'; import { useValues } from 'kea'; -import { EuiText, EuiBadge, EuiIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiSideNavItemType, + EuiText, + EuiBadge, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { SideNavLink, SideNavItem } from '../../../shared/layout'; +import { generateNavLink, SideNavLink, SideNavItem } from '../../../shared/layout'; import { AppLogic } from '../../app_logic'; import { ENGINE_PATH, @@ -47,6 +55,255 @@ import { EngineLogic, generateEnginePath } from './'; import './engine_nav.scss'; +export const useEngineNav = () => { + const isEngineRoute = !!useRouteMatch(ENGINE_PATH); + const { + myRole: { + canViewEngineAnalytics, + canViewEngineDocuments, + canViewEngineSchema, + canViewEngineCrawler, + canViewMetaEngineSourceEngines, + canManageEngineSynonyms, + canManageEngineCurations, + canManageEngineRelevanceTuning, + canManageEngineResultSettings, + canManageEngineSearchUi, + canViewEngineApiLogs, + }, + } = useValues(AppLogic); + const { + engineName, + dataLoading, + isSampleEngine, + isMetaEngine, + hasSchemaErrors, + hasSchemaConflicts, + hasUnconfirmedSchemaFields, + engine, + } = useValues(EngineLogic); + + if (!isEngineRoute) return undefined; + if (dataLoading) return undefined; + if (!engineName) return undefined; + + const navItems: Array> = [ + { + id: 'engineName', + name: engineName, + renderItem: () => ( + +
{engineName.toUpperCase()}
+ {isSampleEngine && ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.sampleEngineBadge', { + defaultMessage: 'SAMPLE ENGINE', + })} + + )} + {isMetaEngine && ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.metaEngineBadge', { + defaultMessage: 'META ENGINE', + })} + + )} +
+ ), + 'data-test-subj': 'EngineLabel', + }, + { + id: 'overview', + name: OVERVIEW_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_PATH) }), + 'data-test-subj': 'EngineOverviewLink', + }, + ]; + + if (canViewEngineAnalytics) { + navItems.push({ + id: 'analytics', + name: ANALYTICS_TITLE, + ...generateNavLink({ + to: generateEnginePath(ENGINE_ANALYTICS_PATH), + shouldShowActiveForSubroutes: true, + }), + 'data-test-subj': 'EngineAnalyticsLink', + }); + } + + if (canViewEngineDocuments) { + navItems.push({ + id: 'documents', + name: DOCUMENTS_TITLE, + ...generateNavLink({ + to: generateEnginePath(ENGINE_DOCUMENTS_PATH), + shouldShowActiveForSubroutes: true, + }), + 'data-test-subj': 'EngineDocumentsLink', + }); + } + + if (canViewEngineSchema) { + navItems.push({ + id: 'schema', + name: SCHEMA_TITLE, + ...generateNavLink({ + to: generateEnginePath(ENGINE_SCHEMA_PATH), + shouldShowActiveForSubroutes: true, + }), + 'data-test-subj': 'EngineSchemaLink', + icon: ( + <> + {hasSchemaErrors && ( + + )} + {hasUnconfirmedSchemaFields && ( + + )} + {hasSchemaConflicts && ( + + )} + + ), + }); + } + + if (canViewEngineCrawler && !isMetaEngine) { + navItems.push({ + id: 'crawler', + name: CRAWLER_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_CRAWLER_PATH) }), + 'data-test-subj': 'EngineCrawlerLink', + }); + } + + if (canViewMetaEngineSourceEngines && isMetaEngine) { + navItems.push({ + id: 'sourceEngines', + name: ENGINES_TITLE, + ...generateNavLink({ to: generateEnginePath(META_ENGINE_SOURCE_ENGINES_PATH) }), + 'data-test-subj': 'MetaEngineEnginesLink', + }); + } + + if (canManageEngineRelevanceTuning) { + const { invalidBoosts, unsearchedUnconfirmedFields } = engine; + + navItems.push({ + id: 'relevanceTuning', + name: RELEVANCE_TUNING_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_RELEVANCE_TUNING_PATH) }), + 'data-test-subj': 'EngineRelevanceTuningLink', + icon: ( + <> + {invalidBoosts && ( + + )} + {unsearchedUnconfirmedFields && ( + + )} + + ), + }); + } + + if (canManageEngineSynonyms) { + navItems.push({ + id: 'synonyms', + name: SYNONYMS_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_SYNONYMS_PATH) }), + 'data-test-subj': 'EngineSynonymsLink', + }); + } + + if (canManageEngineCurations) { + navItems.push({ + id: 'curations', + name: CURATIONS_TITLE, + ...generateNavLink({ + to: generateEnginePath(ENGINE_CURATIONS_PATH), + shouldShowActiveForSubroutes: true, + }), + 'data-test-subj': 'EngineCurationsLink', + }); + } + + if (canManageEngineResultSettings) { + navItems.push({ + id: 'resultSettings', + name: RESULT_SETTINGS_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_RESULT_SETTINGS_PATH) }), + 'data-test-subj': 'EngineResultSettingsLink', + }); + } + + if (canManageEngineSearchUi) { + navItems.push({ + id: 'searchUI', + name: SEARCH_UI_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_SEARCH_UI_PATH) }), + 'data-test-subj': 'EngineSearchUILink', + }); + } + + if (canViewEngineApiLogs) { + navItems.push({ + id: 'apiLogs', + name: API_LOGS_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_API_LOGS_PATH) }), + 'data-test-subj': 'EngineAPILogsLink', + }); + } + + return navItems; +}; + +// TODO: Delete the below once page template migration is complete + export const EngineNav: React.FC = () => { const { myRole: { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index b74c31adca438..ee1c0578debfc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -19,7 +19,6 @@ import { Switch, Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { Loading } from '../../../shared/loading'; import { AnalyticsRouter } from '../analytics'; import { ApiLogs } from '../api_logs'; import { CrawlerRouter } from '../crawler'; @@ -80,20 +79,20 @@ describe('EngineRouter', () => { ); }); - it('renders a loading component if async data is still loading', () => { + it('renders a loading page template if async data is still loading', () => { setMockValues({ ...values, dataLoading: true }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('isLoading')).toEqual(true); }); // This would happen if a user jumps around from one engine route to another. If the engine name // on the path has changed, but we still have an engine stored in state, we do not want to load // any route views as they would be rendering with the wrong data. - it('renders a loading component if the engine stored in state is stale', () => { + it('renders a loading page template if the engine stored in state is stale', () => { setMockValues({ ...values, engineName: 'some-engine' }); mockUseParams.mockReturnValue({ engineName: 'some-new-engine' }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('isLoading')).toEqual(true); }); it('renders a default engine overview', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 40cc2ef0368c0..3e18c9e680de2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -13,11 +13,12 @@ import { useValues, useActions } from 'kea'; import { i18n } from '@kbn/i18n'; import { setQueuedErrorMessage } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; +import { Layout } from '../../../shared/layout'; import { AppLogic } from '../../app_logic'; +import { AppSearchNav } from '../../index'; import { + ENGINE_PATH, ENGINES_PATH, ENGINE_ANALYTICS_PATH, ENGINE_DOCUMENTS_PATH, @@ -38,6 +39,7 @@ import { CrawlerRouter } from '../crawler'; import { CurationsRouter } from '../curations'; import { DocumentDetail, Documents } from '../documents'; import { EngineOverview } from '../engine_overview'; +import { AppSearchPageTemplate } from '../layout'; import { RelevanceTuning } from '../relevance_tuning'; import { ResultSettings } from '../result_settings'; import { SchemaRouter } from '../schema'; @@ -45,7 +47,7 @@ import { SearchUI } from '../search_ui'; import { SourceEngines } from '../source_engines'; import { Synonyms } from '../synonyms'; -import { EngineLogic, getEngineBreadcrumbs } from './'; +import { EngineLogic } from './'; export const EngineRouter: React.FC = () => { const { @@ -85,10 +87,13 @@ export const EngineRouter: React.FC = () => { } const isLoadingNewEngine = engineName !== engineNameFromUrl; - if (isLoadingNewEngine || dataLoading) return ; + if (isLoadingNewEngine || dataLoading) return ; return ( + + + {canViewEngineAnalytics && ( @@ -104,55 +109,54 @@ export const EngineRouter: React.FC = () => { )} - {canViewEngineSchema && ( - - - - )} - {canManageEngineCurations && ( - - - - )} - {canManageEngineRelevanceTuning && ( - - - - )} - {canManageEngineSynonyms && ( - - - - )} - {canManageEngineResultSettings && ( - - - - )} - {canViewEngineApiLogs && ( - - - - )} {canManageEngineSearchUi && ( )} - {canViewMetaEngineSourceEngines && ( - - - - )} - {canViewEngineCrawler && ( - - - - )} - - - - + {/* TODO: Remove layout once page template migration is over */} + }> + {canViewEngineSchema && ( + + + + )} + {canManageEngineCurations && ( + + + + )} + {canManageEngineRelevanceTuning && ( + + + + )} + {canManageEngineSynonyms && ( + + + + )} + {canManageEngineResultSettings && ( + + + + )} + {canViewEngineApiLogs && ( + + + + )} + {canViewMetaEngineSourceEngines && ( + + + + )} + {canViewEngineCrawler && ( + + + + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx index 250c941009ecb..913aa4f0ec845 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx @@ -16,15 +16,13 @@ import { EuiFlexItem, EuiFieldText, EuiSelect, - EuiPageHeader, - EuiPageContent, + EuiPanel, EuiSpacer, EuiTitle, EuiButton, } from '@elastic/eui'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { AppSearchPageTemplate } from '../layout'; import { ALLOWED_CHARS_NOTE, @@ -44,77 +42,77 @@ export const EngineCreation: React.FC = () => { const { setLanguage, setRawName, submitEngine } = useActions(EngineCreationLogic); return ( -
- - - - - -
{ - e.preventDefault(); - submitEngine(); - }} - > - -

{ENGINE_CREATION_FORM_TITLE}

-
- - - - 0 && rawName !== name ? ( - <> - {SANITIZED_NAME_NOTE} {name} - - ) : ( - ALLOWED_CHARS_NOTE - ) - } + + + { + e.preventDefault(); + submitEngine(); + }} + > + +

{ENGINE_CREATION_FORM_TITLE}

+
+ + + + 0 && rawName !== name ? ( + <> + {SANITIZED_NAME_NOTE} {name} + + ) : ( + ALLOWED_CHARS_NOTE + ) + } + fullWidth + > + setRawName(event.currentTarget.value)} + autoComplete="off" fullWidth - > - setRawName(event.currentTarget.value)} - autoComplete="off" - fullWidth - data-test-subj="EngineCreationNameInput" - placeholder={ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER} - autoFocus - /> - - - - - setLanguage(event.currentTarget.value)} - /> - - - - - - {ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL} - - + data-test-subj="EngineCreationNameInput" + placeholder={ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER} + autoFocus + /> +
+
+ + + setLanguage(event.currentTarget.value)} + /> + + +
+ + + {ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL} +
-
-
+ +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx index a3b2f4cfd8b9f..edacd74e046a2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -12,8 +12,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Loading } from '../../../shared/loading'; - import { EmptyEngineOverview } from './engine_overview_empty'; import { EngineOverviewMetrics } from './engine_overview_metrics'; @@ -46,10 +44,10 @@ describe('EngineOverview', () => { expect(actions.pollForOverviewMetrics).toHaveBeenCalledTimes(1); }); - it('renders a loading component if async data is still loading', () => { + it('renders a loading page template if async data is still loading', () => { setMockValues({ ...values, dataLoading: true }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('isLoading')).toEqual(true); }); describe('EmptyEngineOverview', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 77552b36af239..4c15ffd8b7f94 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -9,9 +9,9 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { Loading } from '../../../shared/loading'; import { AppLogic } from '../../app_logic'; import { EngineLogic } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { EmptyEngineOverview } from './engine_overview_empty'; @@ -32,9 +32,7 @@ export const EngineOverview: React.FC = () => { pollForOverviewMetrics(); }, []); - if (dataLoading) { - return ; - } + if (dataLoading) return ; const engineHasDocuments = documentCount > 0; const canAddDocuments = canManageEngineDocuments && canViewEngineCredentials; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx index ea47dc8956ddd..6750ebf1140e0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx @@ -5,13 +5,16 @@ * 2.0. */ +import '../../__mocks__/engine_logic.mock'; + import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiPageHeader, EuiButton } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { docLinks } from '../../../shared/doc_links'; +import { getPageTitle, getPageHeaderActions } from '../../../test_helpers'; import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation'; @@ -25,12 +28,13 @@ describe('EmptyEngineOverview', () => { }); it('renders', () => { - expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Engine setup'); + expect(getPageTitle(wrapper)).toEqual('Engine setup'); }); it('renders a documentation link', () => { - const header = wrapper.find(EuiPageHeader).dive().children().dive(); - expect(header.find(EuiButton).prop('href')).toEqual(`${docLinks.appSearchBase}/index.html`); + expect(getPageHeaderActions(wrapper).find(EuiButton).prop('href')).toEqual( + `${docLinks.appSearchBase}/index.html` + ); }); it('renders document creation components', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx index 959d544a67324..27d9c3723f126 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx @@ -7,35 +7,36 @@ import React from 'react'; -import { EuiPageHeader, EuiPageContentBody, EuiButton } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../shared/flash_messages'; import { DOCS_PREFIX } from '../../routes'; import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation'; +import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; + export const EmptyEngineOverview: React.FC = () => { return ( - <> - {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.overview.empty.headingAction', { defaultMessage: 'View documentation' } )} , - ]} - /> - - - - - - + ], + }} + > + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx index 00ac2af219bff..620d913c5f9a7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx @@ -5,11 +5,13 @@ * 2.0. */ +import '../../__mocks__/engine_logic.mock'; + import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; +import { getPageTitle } from '../../../test_helpers'; import { TotalStats, TotalCharts, RecentApiLogs } from './components'; import { EngineOverviewMetrics } from './engine_overview_metrics'; @@ -18,7 +20,7 @@ describe('EngineOverviewMetrics', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Engine overview'); + expect(getPageTitle(wrapper)).toEqual('Engine overview'); expect(wrapper.find(TotalStats)).toHaveLength(1); expect(wrapper.find(TotalCharts)).toHaveLength(1); expect(wrapper.find(RecentApiLogs)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx index 2b01cfae49a20..b47ae21104ae9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx @@ -7,23 +7,24 @@ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPageHeader, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../shared/flash_messages'; +import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { TotalStats, TotalCharts, RecentApiLogs } from './components'; export const EngineOverviewMetrics: React.FC = () => { return ( - <> - - - + }), + }} + > @@ -34,6 +35,6 @@ export const EngineOverviewMetrics: React.FC = () => { - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.test.tsx index 1eab32d64b77f..8b4f5a69b8141 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.test.tsx @@ -19,7 +19,7 @@ describe('EmptyMetaEnginesState', () => { .find(EuiEmptyPrompt) .dive(); - expect(wrapper.find('h2').text()).toEqual('Create your first meta engine'); + expect(wrapper.find('h3').text()).toEqual('Create your first meta engine'); expect(wrapper.find(EuiButton).prop('href')).toEqual( expect.stringContaining('/meta-engines-guide.html') ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx index 58bf3f0a0195e..ad96f21022f2b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx @@ -15,12 +15,13 @@ import { DOCS_PREFIX } from '../../../routes'; export const EmptyMetaEnginesState: React.FC = () => ( +

{i18n.translate('xpack.enterpriseSearch.appSearch.engines.metaEngines.emptyPromptTitle', { defaultMessage: 'Create your first meta engine', })} -

+ } + titleSize="s" body={

{i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx index e6a7c03d2aab4..df17d22d387d9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useValues, useActions } from 'kea'; -import { EuiPageContent, EuiEmptyPrompt, EuiSpacer } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { EuiButtonTo } from '../../../../shared/react_router_helpers'; @@ -20,86 +20,72 @@ import { ENGINE_CREATION_PATH } from '../../../routes'; import { SampleEngineCreationCta } from '../../sample_engine_creation_cta/sample_engine_creation_cta'; -import { EnginesOverviewHeader } from './header'; - export const EmptyState: React.FC = () => { const { myRole: { canManageEngines }, } = useValues(AppLogic); const { sendAppSearchTelemetry } = useActions(TelemetryLogic); - return ( - <> - - - {canManageEngines ? ( - - {i18n.translate('xpack.enterpriseSearch.appSearch.emptyState.title', { - defaultMessage: 'Create your first engine', - })} - - } - titleSize="l" - body={ -

- {i18n.translate('xpack.enterpriseSearch.appSearch.emptyState.description1', { - defaultMessage: - 'An App Search engine stores the documents for your search experience.', - })} -

- } - actions={ - <> - - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'create_first_engine_button', - }) - } - > - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta', - { defaultMessage: 'Create an engine' } - )} - - - - - } - /> - ) : ( - - {i18n.translate('xpack.enterpriseSearch.appSearch.emptyState.nonAdmin.title', { - defaultMessage: 'No engines available', - })} - - } - body={ -

- {i18n.translate( - 'xpack.enterpriseSearch.appSearch.emptyState.nonAdmin.description', - { - defaultMessage: - 'Contact your App Search administrator to either create or grant you access to an engine.', - } - )} -

+ return canManageEngines ? ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.emptyState.title', { + defaultMessage: 'Create your first engine', + })} + + } + titleSize="l" + body={ +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.emptyState.description1', { + defaultMessage: 'An App Search engine stores the documents for your search experience.', + })} +

+ } + actions={ + <> + + sendAppSearchTelemetry({ + action: 'clicked', + metric: 'create_first_engine_button', + }) } - /> - )} - - + > + {i18n.translate('xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta', { + defaultMessage: 'Create an engine', + })} + + + + + } + /> + ) : ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.emptyState.nonAdmin.title', { + defaultMessage: 'No engines available', + })} + + } + body={ +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.emptyState.nonAdmin.description', { + defaultMessage: + 'Contact your App Search administrator to either create or grant you access to an engine.', + })} +

+ } + /> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx deleted file mode 100644 index bab67fd0e4bb5..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { useActions } from 'kea'; - -import { EuiPageHeader, EuiButton } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { getAppSearchUrl } from '../../../../shared/enterprise_search_url'; -import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; -import { TelemetryLogic } from '../../../../shared/telemetry'; - -import { ENGINES_TITLE } from '../constants'; - -export const EnginesOverviewHeader: React.FC = () => { - const { sendAppSearchTelemetry } = useActions(TelemetryLogic); - - return ( - <> - - - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'header_launch_button', - }) - } - data-test-subj="launchButton" - > - {i18n.translate('xpack.enterpriseSearch.appSearch.productCta', { - defaultMessage: 'Launch App Search', - })} - , - ]} - /> - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts index 234d3ba31f44b..1d8e578e0edf2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -export { EnginesOverviewHeader } from './header'; -export { LoadingState } from './loading_state'; +export { LaunchAppSearchButton } from './launch_as_button'; export { EmptyState } from './empty_state'; export { EmptyMetaEnginesState } from './empty_meta_engines_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.test.tsx similarity index 64% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.test.tsx index 9b245a468b083..93c91cc3830f4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.test.tsx @@ -12,23 +12,11 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; - -import { EnginesOverviewHeader } from './'; - -describe('EnginesOverviewHeader', () => { - const wrapper = shallow() - .find(EuiPageHeader) - .dive() - .children() - .dive(); - - it('renders', () => { - expect(wrapper.find('h1').text()).toEqual('Engines overview'); - }); +import { LaunchAppSearchButton } from './'; +describe('LaunchAppSearchButton', () => { it('renders a launch app search button that sends telemetry on click', () => { - const button = wrapper.find('[data-test-subj="launchButton"]'); + const button = shallow(); expect(button.prop('href')).toBe('http://localhost:3002/as'); expect(button.prop('isDisabled')).toBeFalsy(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.tsx new file mode 100644 index 0000000000000..41102cb4fba2e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions } from 'kea'; + +import { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { getAppSearchUrl } from '../../../../shared/enterprise_search_url'; +import { TelemetryLogic } from '../../../../shared/telemetry'; + +export const LaunchAppSearchButton: React.FC = () => { + const { sendAppSearchTelemetry } = useActions(TelemetryLogic); + + return ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + sendAppSearchTelemetry({ + action: 'clicked', + metric: 'header_launch_button', + }) + } + data-test-subj="launchButton" + > + {i18n.translate('xpack.enterpriseSearch.appSearch.productCta', { + defaultMessage: 'Launch App Search', + })} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/loading_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/loading_state.test.tsx deleted file mode 100644 index f7ccfea4bb4d4..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/loading_state.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiLoadingContent } from '@elastic/eui'; - -import { LoadingState } from './'; - -describe('LoadingState', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiLoadingContent)).toHaveLength(2); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/loading_state.tsx deleted file mode 100644 index 875c47378d1fb..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/loading_state.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { EuiPageContent, EuiSpacer, EuiLoadingContent } from '@elastic/eui'; - -import { EnginesOverviewHeader } from './header'; - -export const LoadingState: React.FC = () => { - return ( - <> - - - - - - - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.tsx similarity index 57% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.tsx index 8d03e3d23ae23..223c33f9b9592 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.tsx @@ -5,17 +5,50 @@ * 2.0. */ +import React from 'react'; + +import { EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { DOCS_PREFIX } from '../../routes'; +import { + META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION, + META_ENGINE_CREATION_FORM_DOCUMENTATION_LINK, +} from '../meta_engine_creation/constants'; export const ENGINES_TITLE = i18n.translate('xpack.enterpriseSearch.appSearch.engines.title', { defaultMessage: 'Engines', }); +export const ENGINES_OVERVIEW_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.title', + { defaultMessage: 'Engines overview' } +); + export const META_ENGINES_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.metaEngines.title', { defaultMessage: 'Meta Engines' } ); +export const META_ENGINES_DESCRIPTION = ( + <> + {META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION} +
+ + {META_ENGINE_CREATION_FORM_DOCUMENTATION_LINK} + + ), + }} + /> + +); + export const SOURCE_ENGINES_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.enginesOverview.metaEnginesTable.sourceEngines.title', { defaultMessage: 'Source Engines' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx index 27fe65fe518eb..a90e1369593d9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx @@ -14,7 +14,6 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { rerender } from '../../../test_helpers'; -import { LoadingState, EmptyState } from './components'; import { EnginesTable } from './components/tables/engines_table'; import { MetaEnginesTable } from './components/tables/meta_engines_table'; @@ -43,7 +42,7 @@ describe('EnginesOverview', () => { metaEnginesLoading: false, hasPlatinumLicense: false, // AppLogic - myRole: { canManageEngines: false }, + myRole: { canManageEngines: false, canManageMetaEngines: false }, // MetaEnginesTableLogic expandedSourceEngines: {}, conflictingEnginesSets: {}, @@ -61,135 +60,147 @@ describe('EnginesOverview', () => { setMockActions(actions); }); - describe('non-happy-path states', () => { - it('isLoading', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); + const valuesWithEngines = { + ...values, + dataLoading: false, + engines: ['test-engine'], + enginesMeta: { + page: { + current: 1, + size: 10, + total_results: 100, + }, + }, + }; - expect(wrapper.find(LoadingState)).toHaveLength(1); - }); + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(valuesWithEngines); + }); + + it('renders and calls the engines API', () => { + const wrapper = shallow(); - it('isEmpty', () => { - setMockValues({ ...values, engines: [] }); + expect(wrapper.find(EnginesTable)).toHaveLength(1); + expect(actions.loadEngines).toHaveBeenCalled(); + }); + + describe('engine creation', () => { + it('renders a create engine action when the users can create engines', () => { + setMockValues({ + ...valuesWithEngines, + myRole: { canManageEngines: true }, + }); const wrapper = shallow(); - expect(wrapper.find(EmptyState)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="appSearchEngines"]').prop('action')).toBeTruthy(); }); - }); - describe('happy-path states', () => { - const valuesWithEngines = { - ...values, - dataLoading: false, - engines: ['test-engine'], - enginesMeta: { - page: { - current: 1, - size: 10, - total_results: 100, - }, - }, - }; + it('does not render a create engine action if the user cannot create engines', () => { + setMockValues({ + ...valuesWithEngines, + myRole: { canManageEngines: false }, + }); + const wrapper = shallow(); - beforeEach(() => { - jest.clearAllMocks(); - setMockValues(valuesWithEngines); + expect(wrapper.find('[data-test-subj="appSearchEngines"]').prop('action')).toBeFalsy(); }); + }); - it('renders and calls the engines API', () => { + describe('when the account has a platinum license', () => { + it('renders a 2nd meta engines table & makes a 2nd meta engines call', () => { + setMockValues({ + ...valuesWithEngines, + hasPlatinumLicense: true, + }); const wrapper = shallow(); - expect(wrapper.find(EnginesTable)).toHaveLength(1); - expect(actions.loadEngines).toHaveBeenCalled(); + expect(wrapper.find(MetaEnginesTable)).toHaveLength(1); + expect(actions.loadMetaEngines).toHaveBeenCalled(); }); - describe('when the user can manage/create engines', () => { - it('renders a create engine button which takes users to the create engine page', () => { + describe('meta engine creation', () => { + it('renders a create meta engine action when the user can create meta engines', () => { setMockValues({ ...valuesWithEngines, - myRole: { canManageEngines: true }, + hasPlatinumLicense: true, + myRole: { canManageMetaEngines: true }, }); const wrapper = shallow(); - expect( - wrapper.find('[data-test-subj="appSearchEnginesEngineCreationButton"]').prop('to') - ).toEqual('/engine_creation'); + expect(wrapper.find('[data-test-subj="appSearchMetaEngines"]').prop('action')).toBeTruthy(); }); - }); - describe('when the account has a platinum license', () => { - it('renders a 2nd meta engines table & makes a 2nd meta engines call', () => { + it('does not render a create meta engine action if user cannot create meta engines', () => { setMockValues({ ...valuesWithEngines, hasPlatinumLicense: true, + myRole: { canManageMetaEngines: false }, }); const wrapper = shallow(); - expect(wrapper.find(MetaEnginesTable)).toHaveLength(1); - expect(actions.loadMetaEngines).toHaveBeenCalled(); + expect(wrapper.find('[data-test-subj="appSearchMetaEngines"]').prop('action')).toBeFalsy(); }); + }); + }); - describe('when the user can manage/create engines', () => { - it('renders a create engine button which takes users to the create meta engine page', () => { - setMockValues({ - ...valuesWithEngines, - hasPlatinumLicense: true, - myRole: { canManageEngines: true }, - }); - const wrapper = shallow(); - - expect( - wrapper.find('[data-test-subj="appSearchEnginesMetaEngineCreationButton"]').prop('to') - ).toEqual('/meta_engine_creation'); - }); + describe('when an account does not have a platinum license', () => { + it('renders a license call to action in place of the meta engines table', () => { + setMockValues({ + ...valuesWithEngines, + hasPlatinumLicense: false, }); + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="metaEnginesLicenseCTA"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="appSearchMetaEngines"]')).toHaveLength(0); }); + }); - describe('pagination', () => { - const getTablePagination = (wrapper: ShallowWrapper) => - wrapper.find(EnginesTable).prop('pagination'); + describe('pagination', () => { + const getTablePagination = (wrapper: ShallowWrapper) => + wrapper.find(EnginesTable).prop('pagination'); - it('passes down page data from the API', () => { - const wrapper = shallow(); - const pagination = getTablePagination(wrapper); + it('passes down page data from the API', () => { + const wrapper = shallow(); + const pagination = getTablePagination(wrapper); - expect(pagination.totalItemCount).toEqual(100); - expect(pagination.pageIndex).toEqual(0); - }); + expect(pagination.totalItemCount).toEqual(100); + expect(pagination.pageIndex).toEqual(0); + }); - it('re-polls the API on page change', () => { - const wrapper = shallow(); + it('re-polls the API on page change', () => { + const wrapper = shallow(); - setMockValues({ - ...valuesWithEngines, - enginesMeta: { - page: { - ...valuesWithEngines.enginesMeta.page, - current: 51, - }, + setMockValues({ + ...valuesWithEngines, + enginesMeta: { + page: { + ...valuesWithEngines.enginesMeta.page, + current: 51, }, - }); - rerender(wrapper); - - expect(actions.loadEngines).toHaveBeenCalledTimes(2); - expect(getTablePagination(wrapper).pageIndex).toEqual(50); + }, }); + rerender(wrapper); - it('calls onPagination handlers', () => { - setMockValues({ - ...valuesWithEngines, - hasPlatinumLicense: true, - metaEngines: ['test-meta-engine'], - }); - const wrapper = shallow(); - const pageEvent = { page: { index: 0 } }; - - wrapper.find(EnginesTable).simulate('change', pageEvent); - expect(actions.onEnginesPagination).toHaveBeenCalledWith(1); + expect(actions.loadEngines).toHaveBeenCalledTimes(2); + expect(getTablePagination(wrapper).pageIndex).toEqual(50); + }); - wrapper.find(MetaEnginesTable).simulate('change', pageEvent); - expect(actions.onMetaEnginesPagination).toHaveBeenCalledWith(1); + it('calls onPagination handlers', () => { + setMockValues({ + ...valuesWithEngines, + hasPlatinumLicense: true, + metaEngines: ['test-meta-engine'], }); + const wrapper = shallow(); + const pageEvent = { page: { index: 0 } }; + + wrapper.find(EnginesTable).simulate('change', pageEvent); + expect(actions.onEnginesPagination).toHaveBeenCalledWith(1); + + wrapper.find(MetaEnginesTable).simulate('change', pageEvent); + expect(actions.onMetaEnginesPagination).toHaveBeenCalledWith(1); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index 7001ecada999a..4dff246052138 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -9,46 +9,34 @@ import React, { useEffect } from 'react'; import { useValues, useActions } from 'kea'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiPageContent, - EuiPageContentHeader, - EuiPageContentHeaderSection, - EuiPageContentBody, - EuiTitle, - EuiSpacer, -} from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { LicensingLogic } from '../../../shared/licensing'; +import { LicensingLogic, ManageLicenseButton } from '../../../shared/licensing'; import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { convertMetaToPagination, handlePageChange } from '../../../shared/table_pagination'; -import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; import { EngineIcon, MetaEngineIcon } from '../../icons'; import { ENGINE_CREATION_PATH, META_ENGINE_CREATION_PATH } from '../../routes'; +import { DataPanel } from '../data_panel'; +import { AppSearchPageTemplate } from '../layout'; -import { - EnginesOverviewHeader, - LoadingState, - EmptyState, - EmptyMetaEnginesState, -} from './components'; +import { LaunchAppSearchButton, EmptyState, EmptyMetaEnginesState } from './components'; import { EnginesTable } from './components/tables/engines_table'; import { MetaEnginesTable } from './components/tables/meta_engines_table'; import { + ENGINES_OVERVIEW_TITLE, CREATE_AN_ENGINE_BUTTON_LABEL, CREATE_A_META_ENGINE_BUTTON_LABEL, ENGINES_TITLE, META_ENGINES_TITLE, + META_ENGINES_DESCRIPTION, } from './constants'; import { EnginesLogic } from './engines_logic'; export const EnginesOverview: React.FC = () => { const { hasPlatinumLicense } = useValues(LicensingLogic); const { - myRole: { canManageEngines }, + myRole: { canManageEngines, canManageMetaEngines }, } = useValues(AppLogic); const { @@ -73,102 +61,93 @@ export const EnginesOverview: React.FC = () => { if (hasPlatinumLicense) loadMetaEngines(); }, [hasPlatinumLicense, metaEnginesMeta.page.current]); - if (dataLoading) return ; - if (!engines.length) return ; - return ( - <> - - - - - - - - - - - - - -

{ENGINES_TITLE}

-
-
-
-
- - {canManageEngines && ( + ], + }} + isLoading={dataLoading} + isEmptyState={!engines.length} + emptyState={} + > + {ENGINES_TITLE}} + titleSize="s" + action={ + canManageEngines && ( + + {CREATE_AN_ENGINE_BUTTON_LABEL} + + ) + } + data-test-subj="appSearchEngines" + > + + + + {hasPlatinumLicense ? ( + {META_ENGINES_TITLE}} + titleSize="s" + action={ + canManageMetaEngines && ( - {CREATE_AN_ENGINE_BUTTON_LABEL} + {CREATE_A_META_ENGINE_BUTTON_LABEL} - )} - -
- - - + } + onChange={handlePageChange(onMetaEnginesPagination)} /> - - - {hasPlatinumLicense && ( - <> - - - - - - - - - -

{META_ENGINES_TITLE}

-
-
-
-
- - {canManageEngines && ( - - {CREATE_A_META_ENGINE_BUTTON_LABEL} - - )} - -
- - - } - onChange={handlePageChange(onMetaEnginesPagination)} - /> - - - )} -
- +
+ ) : ( + {META_ENGINES_TITLE}} + titleSize="s" + subtitle={META_ENGINES_DESCRIPTION} + action={} + data-test-subj="metaEnginesLicenseCTA" + /> + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx index 8b06f4b26835d..80230394ce2a2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx @@ -10,6 +10,9 @@ import { setMockValues } from '../../../__mocks__/kea_logic'; jest.mock('../../../shared/layout', () => ({ generateNavLink: jest.fn(({ to }) => ({ href: to })), })); +jest.mock('../engine/engine_nav', () => ({ + useEngineNav: () => [], +})); import { useAppSearchNav } from './nav'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx index 57fa740caebec..4737fbcf07e23 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx @@ -15,6 +15,7 @@ import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; import { AppLogic } from '../../app_logic'; import { ENGINES_PATH, SETTINGS_PATH, CREDENTIALS_PATH, ROLE_MAPPINGS_PATH } from '../../routes'; import { CREDENTIALS_TITLE } from '../credentials'; +import { useEngineNav } from '../engine/engine_nav'; import { ENGINES_TITLE } from '../engines'; import { SETTINGS_TITLE } from '../settings'; @@ -28,7 +29,7 @@ export const useAppSearchNav = () => { id: 'engines', name: ENGINES_TITLE, ...generateNavLink({ to: ENGINES_PATH, isRoot: true }), - items: [], // TODO: Engine nav + items: useEngineNav(), }, ]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx index 02a1768a7528e..325e557acec0c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx @@ -18,16 +18,14 @@ import { EuiFormRow, EuiFlexItem, EuiFieldText, - EuiPageContent, - EuiPageHeader, + EuiPanel, EuiSpacer, EuiTitle, EuiButton, } from '@elastic/eui'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { AppLogic } from '../../app_logic'; +import { AppSearchPageTemplate } from '../layout'; import { ALLOWED_CHARS_NOTE, @@ -74,20 +72,21 @@ export const MetaEngineCreation: React.FC = () => { }, []); return ( -
- - {META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION}
{META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION} - } - /> - - + ), + }} + data-test-subj="MetaEngineCreation" + > + { {META_ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL} - -
+ + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.test.tsx new file mode 100644 index 0000000000000..39f0cb376b325 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; + +import { EmptyState } from './empty_state'; + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow() + .find(EuiEmptyPrompt) + .dive(); + + expect(wrapper.find('h2').text()).toEqual('Add documents to generate a Search UI'); + expect(wrapper.find(EuiButton).prop('href')).toEqual( + expect.stringContaining('/reference-ui-guide.html') + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.tsx new file mode 100644 index 0000000000000..b7665a58de300 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.tsx @@ -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 React from 'react'; + +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { DOCS_PREFIX } from '../../../routes'; + +export const EmptyState: React.FC = () => ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.searchUI.empty.title', { + defaultMessage: 'Add documents to generate a Search UI', + })} + + } + body={ +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.engine.searchUI.empty.description', { + defaultMessage: + 'A schema will be automatically created for you after you index some documents.', + })} +

+ } + actions={ + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.searchUI.empty.buttonLabel', { + defaultMessage: 'Read the Search UI guide', + })} + + } + /> +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx index edec376dd3edd..f9f0dd611b953 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx @@ -6,14 +6,17 @@ */ import '../../../__mocks__/shallow_useeffect.mock'; -import '../../__mocks__/engine_logic.mock'; -import { setMockActions } from '../../../__mocks__/kea_logic'; +import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; +import { mockEngineValues } from '../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; +import { SearchUIForm } from './components/search_ui_form'; +import { SearchUIGraphic } from './components/search_ui_graphic'; + import { SearchUI } from './'; describe('SearchUI', () => { @@ -24,11 +27,13 @@ describe('SearchUI', () => { beforeEach(() => { jest.clearAllMocks(); setMockActions(actions); + setMockValues(mockEngineValues); }); it('renders', () => { - shallow(); - // TODO: Check for form + const wrapper = shallow(); + expect(wrapper.find(SearchUIForm).exists()).toBe(true); + expect(wrapper.find(SearchUIGraphic).exists()).toBe(true); }); it('initializes data on mount', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx index e75bc36177151..0ac59a33068ba 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx @@ -7,25 +7,16 @@ import React, { useEffect } from 'react'; -import { useActions } from 'kea'; +import { useActions, useValues } from 'kea'; -import { - EuiPageHeader, - EuiPageContentBody, - EuiText, - EuiFlexItem, - EuiFlexGroup, - EuiSpacer, - EuiLink, -} from '@elastic/eui'; +import { EuiText, EuiFlexItem, EuiFlexGroup, EuiSpacer, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; - import { DOCS_PREFIX } from '../../routes'; -import { getEngineBreadcrumbs } from '../engine'; +import { EngineLogic, getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; +import { EmptyState } from './components/empty_state'; import { SearchUIForm } from './components/search_ui_form'; import { SearchUIGraphic } from './components/search_ui_graphic'; import { SEARCH_UI_TITLE } from './i18n'; @@ -33,61 +24,62 @@ import { SearchUILogic } from './search_ui_logic'; export const SearchUI: React.FC = () => { const { loadFieldData } = useActions(SearchUILogic); + const { engine } = useValues(EngineLogic); useEffect(() => { loadFieldData(); }, []); return ( - <> - - - - - - - -

- - - - ), - }} - /> -

-

- - - - ), - }} - /> -

-
- - -
- - - -
-
- + } + > + + + +

+ + + + ), + }} + /> +

+

+ + + + ), + }} + /> +

+
+ + +
+ + + +
+
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_panel.tsx index 76fdcdac58ad4..fb4b503c7e62c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_panel.tsx @@ -9,7 +9,15 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiLink, EuiSpacer, EuiSwitch, EuiText, EuiTextColor, EuiTitle } from '@elastic/eui'; +import { + EuiPanel, + EuiLink, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTextColor, + EuiTitle, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DOCS_PREFIX } from '../../../routes'; @@ -30,7 +38,7 @@ export const LogRetentionPanel: React.FC = () => { }, []); return ( -
+

{i18n.translate('xpack.enterpriseSearch.appSearch.settings.logRetention.title', { @@ -104,6 +112,6 @@ export const LogRetentionPanel: React.FC = () => { data-test-subj="LogRetentionPanelAPISwitch" /> -

+ ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.test.tsx index 41d446b8e36fc..1ad12856a92e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.test.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageContentBody } from '@elastic/eui'; +import { LogRetentionPanel } from './log_retention'; import { Settings } from './settings'; describe('Settings', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiPageContentBody)).toHaveLength(1); + expect(wrapper.find(LogRetentionPanel)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.tsx index 2d5dd08f81288..ddbf046d75ec1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.tsx @@ -7,10 +7,7 @@ import React from 'react'; -import { EuiPageHeader, EuiPageContent, EuiPageContentBody } from '@elastic/eui'; - -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { AppSearchPageTemplate } from '../layout'; import { LogRetentionPanel, LogRetentionConfirmationModal } from './log_retention'; @@ -18,16 +15,9 @@ import { SETTINGS_TITLE } from './'; export const Settings: React.FC = () => { return ( - <> - - - - - - - - - - + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 4d8ff80326715..2402a6ecc6401 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -24,6 +24,7 @@ import { rerender } from '../test_helpers'; jest.mock('./app_logic', () => ({ AppLogic: jest.fn() })); import { AppLogic } from './app_logic'; +import { Credentials } from './components/credentials'; import { EngineRouter, EngineNav } from './components/engine'; import { EngineCreation } from './components/engine_creation'; import { EnginesOverview } from './components/engines'; @@ -31,6 +32,7 @@ import { ErrorConnecting } from './components/error_connecting'; import { Library } from './components/library'; import { MetaEngineCreation } from './components/meta_engine_creation'; import { RoleMappings } from './components/role_mappings'; +import { Settings } from './components/settings'; import { SetupGuide } from './components/setup_guide'; import { AppSearch, AppSearchUnconfigured, AppSearchConfigured, AppSearchNav } from './'; @@ -103,52 +105,28 @@ describe('AppSearchConfigured', () => { expect(wrapper.find(Layout).first().prop('readOnlyMode')).toEqual(true); }); - describe('ability checks', () => { - describe('canViewRoleMappings', () => { - it('renders RoleMappings when canViewRoleMappings is true', () => { - setMockValues({ myRole: { canViewRoleMappings: true } }); - rerender(wrapper); - expect(wrapper.find(RoleMappings)).toHaveLength(1); + describe('routes with ability checks', () => { + const runRouteAbilityCheck = (routeAbility: string, View: React.FC) => { + describe(View.name, () => { + it(`renders ${View.name} when user ${routeAbility} is true`, () => { + setMockValues({ myRole: { [routeAbility]: true } }); + rerender(wrapper); + expect(wrapper.find(View)).toHaveLength(1); + }); + + it(`does not render ${View.name} when user ${routeAbility} is false`, () => { + setMockValues({ myRole: { [routeAbility]: false } }); + rerender(wrapper); + expect(wrapper.find(View)).toHaveLength(0); + }); }); + }; - it('does not render RoleMappings when user canViewRoleMappings is false', () => { - setMockValues({ myRole: { canManageEngines: false } }); - rerender(wrapper); - expect(wrapper.find(RoleMappings)).toHaveLength(0); - }); - }); - - describe('canManageEngines', () => { - it('renders EngineCreation when user canManageEngines is true', () => { - setMockValues({ myRole: { canManageEngines: true } }); - rerender(wrapper); - - expect(wrapper.find(EngineCreation)).toHaveLength(1); - }); - - it('does not render EngineCreation when user canManageEngines is false', () => { - setMockValues({ myRole: { canManageEngines: false } }); - rerender(wrapper); - - expect(wrapper.find(EngineCreation)).toHaveLength(0); - }); - }); - - describe('canManageMetaEngines', () => { - it('renders MetaEngineCreation when user canManageMetaEngines is true', () => { - setMockValues({ myRole: { canManageMetaEngines: true } }); - rerender(wrapper); - - expect(wrapper.find(MetaEngineCreation)).toHaveLength(1); - }); - - it('does not render MetaEngineCreation when user canManageMetaEngines is false', () => { - setMockValues({ myRole: { canManageMetaEngines: false } }); - rerender(wrapper); - - expect(wrapper.find(MetaEngineCreation)).toHaveLength(0); - }); - }); + runRouteAbilityCheck('canViewSettings', Settings); + runRouteAbilityCheck('canViewAccountCredentials', Credentials); + runRouteAbilityCheck('canViewRoleMappings', RoleMappings); + runRouteAbilityCheck('canManageEngines', EngineCreation); + runRouteAbilityCheck('canManageMetaEngines', MetaEngineCreation); }); describe('library', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index caf0f805e8ca7..7b3b13aef05d6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -76,7 +76,13 @@ export const AppSearchUnconfigured: React.FC = () => ( export const AppSearchConfigured: React.FC> = (props) => { const { - myRole: { canManageEngines, canManageMetaEngines, canViewRoleMappings }, + myRole: { + canManageEngines, + canManageMetaEngines, + canViewSettings, + canViewAccountCredentials, + canViewRoleMappings, + }, } = useValues(AppLogic(props)); const { renderHeaderActions } = useValues(KibanaLogic); const { readOnlyMode } = useValues(HttpLogic); @@ -92,6 +98,35 @@ export const AppSearchConfigured: React.FC> = (props) = )} + + + + + + + + + + {canManageEngines && ( + + + + )} + {canManageMetaEngines && ( + + + + )} + {canViewSettings && ( + + + + )} + {canViewAccountCredentials && ( + + + + )} {canViewRoleMappings && ( @@ -100,31 +135,6 @@ export const AppSearchConfigured: React.FC> = (props) = } readOnlyMode={readOnlyMode}> - - - - - - - - - - - - - - - - {canManageEngines && ( - - - - )} - {canManageMetaEngines && ( - - - - )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts index 4d4c84e4146ef..60d0dcc0c5911 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts @@ -10,7 +10,7 @@ import { DEFAULT_INITIAL_APP_DATA } from '../../../../../common/__mocks__'; import { getRoleAbilities } from './'; describe('getRoleAbilities', () => { - const mockRole = DEFAULT_INITIAL_APP_DATA.appSearch.role; + const mockRole = DEFAULT_INITIAL_APP_DATA.appSearch.role as any; it('transforms server role data into a flat role obj with helper shorthands', () => { expect(getRoleAbilities(mockRole)).toEqual({ @@ -53,9 +53,10 @@ describe('getRoleAbilities', () => { describe('can()', () => { it('sets view abilities to true if manage abilities are true', () => { - const role = { ...mockRole }; - role.ability.view = []; - role.ability.manage = ['account_settings']; + const role = { + ...mockRole, + ability: { view: [], manage: ['account_settings'] }, + }; const myRole = getRoleAbilities(role); @@ -70,4 +71,26 @@ describe('getRoleAbilities', () => { expect(myRole.can('edit', 'fakeSubject')).toEqual(false); }); }); + + describe('canManageMetaEngines', () => { + const canManageEngines = { ability: { manage: ['account_engines'] } }; + + it('returns true when the user can manage any engines and the account has a platinum license', () => { + const myRole = getRoleAbilities({ ...mockRole, ...canManageEngines }, true); + + expect(myRole.canManageMetaEngines).toEqual(true); + }); + + it('returns false when the user can manage any engines but the account does not have a platinum license', () => { + const myRole = getRoleAbilities({ ...mockRole, ...canManageEngines }, false); + + expect(myRole.canManageMetaEngines).toEqual(false); + }); + + it('returns false when has a platinum license but the user cannot manage any engines', () => { + const myRole = getRoleAbilities({ ...mockRole, ability: { manage: [] } }, true); + + expect(myRole.canManageMetaEngines).toEqual(false); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts index 81ac971d00d44..ef3e22d851f38 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts @@ -13,7 +13,7 @@ import { RoleTypes, AbilityTypes, Role } from './types'; * Transforms the `role` data we receive from the Enterprise Search * server into a more convenient format for front-end use */ -export const getRoleAbilities = (role: Account['role']): Role => { +export const getRoleAbilities = (role: Account['role'], hasPlatinumLicense = false): Role => { // Role ability function helpers const myRole = { can: (action: AbilityTypes, subject: string): boolean => { @@ -49,7 +49,7 @@ export const getRoleAbilities = (role: Account['role']): Role => { canViewSettings: myRole.can('view', 'account_settings'), canViewRoleMappings: myRole.can('view', 'role_mappings'), canManageEngines: myRole.can('manage', 'account_engines'), - canManageMetaEngines: myRole.can('manage', 'account_meta_engines'), + canManageMetaEngines: hasPlatinumLicense && myRole.can('manage', 'account_engines'), canManageLogSettings: myRole.can('manage', 'account_log_settings'), canManageSettings: myRole.can('manage', 'account_settings'), canManageEngineCrawler: myRole.can('manage', 'engine_crawler'), diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/constants.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/constants.ts index 903d1768f3cc1..f51eeb1c8160c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/constants.ts @@ -11,10 +11,3 @@ export const LICENSE_CALLOUT_BODY = i18n.translate('xpack.enterpriseSearch.licen defaultMessage: 'Enterprise authentication via SAML, document-level permission and authorization support, custom search experiences and more are available with a valid Platinum license.', }); - -export const LICENSE_CALLOUT_BUTTON = i18n.translate( - 'xpack.enterpriseSearch.licenseCalloutButton', - { - defaultMessage: 'Manage your license', - } -); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.test.tsx index 0c77a0fbf6f5a..75a9700691ebb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.test.tsx @@ -13,7 +13,7 @@ import { shallow } from 'enzyme'; import { EuiPanel, EuiText } from '@elastic/eui'; -import { EuiButtonTo } from '../../../shared/react_router_helpers'; +import { ManageLicenseButton } from '../../../shared/licensing'; import { LicenseCallout } from './'; @@ -27,9 +27,7 @@ describe('LicenseCallout', () => { expect(wrapper.find(EuiPanel)).toHaveLength(1); expect(wrapper.find(EuiText)).toHaveLength(2); - expect(wrapper.find(EuiButtonTo).prop('to')).toEqual( - '/app/management/stack/license_management' - ); + expect(wrapper.find(ManageLicenseButton)).toHaveLength(1); }); it('does not render for platinum', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.tsx index 4a4de17450f1b..f9f329c859110 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.tsx @@ -11,12 +11,11 @@ import { useValues } from 'kea'; import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { LicensingLogic } from '../../../shared/licensing'; -import { EuiButtonTo } from '../../../shared/react_router_helpers'; +import { LicensingLogic, ManageLicenseButton } from '../../../shared/licensing'; import { PRODUCT_SELECTOR_CALLOUT_HEADING } from '../../constants'; -import { LICENSE_CALLOUT_BODY, LICENSE_CALLOUT_BUTTON } from './constants'; +import { LICENSE_CALLOUT_BODY } from './constants'; export const LicenseCallout: React.FC = () => { const { hasPlatinumLicense, isTrial } = useValues(LicensingLogic); @@ -34,9 +33,7 @@ export const LicenseCallout: React.FC = () => { - - {LICENSE_CALLOUT_BUTTON} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index ba2b28e64b9cf..414957656467a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -57,6 +57,7 @@ export const renderApp = ( }); const unmountLicensingLogic = mountLicensingLogic({ license$: plugins.licensing.license$, + canManageLicense: core.application.capabilities.management?.stack?.license_management, }); const unmountHttpLogic = mountHttpLogic({ http: core.http, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts index 4cc907c3de9e4..39392d0c5c78e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts @@ -33,6 +33,12 @@ describe('KibanaLogic', () => { expect(KibanaLogic.values.config).toEqual({}); }); + it('gracefully handles disabled security', () => { + mountKibanaLogic({ ...mockKibanaValues, security: undefined } as any); + + expect(KibanaLogic.values.security).toEqual({}); + }); + it('gracefully handles non-cloud installs', () => { mountKibanaLogic({ ...mockKibanaValues, cloud: undefined } as any); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts index c83e578bdd090..74281d45ae0a5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts @@ -6,3 +6,4 @@ */ export { LicensingLogic, mountLicensingLogic } from './licensing_logic'; +export { ManageLicenseButton } from './manage_license_button'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.test.ts index 4ea74e1c0d4f2..5d210cee1a926 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.test.ts @@ -15,13 +15,21 @@ import { LicensingLogic, mountLicensingLogic } from './licensing_logic'; describe('LicensingLogic', () => { const mockLicense = licensingMock.createLicense(); const mockLicense$ = new BehaviorSubject(mockLicense); - const mount = () => mountLicensingLogic({ license$: mockLicense$ }); + const mount = (props?: object) => + mountLicensingLogic({ license$: mockLicense$, canManageLicense: true, ...props }); beforeEach(() => { jest.clearAllMocks(); resetContext({}); }); + describe('canManageLicense', () => { + it('sets value from props', () => { + mount({ canManageLicense: false }); + expect(LicensingLogic.values.canManageLicense).toEqual(false); + }); + }); + describe('setLicense()', () => { it('sets license value', () => { mount(); @@ -61,7 +69,7 @@ describe('LicensingLogic', () => { describe('on unmount', () => { it('unsubscribes to the license observable', () => { const mockUnsubscribe = jest.fn(); - const unmount = mountLicensingLogic({ + const unmount = mount({ license$: { subscribe: () => ({ unsubscribe: mockUnsubscribe }) } as any, }); unmount(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.ts index 7d0222f476214..f94a1fff0cd31 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.ts @@ -16,6 +16,7 @@ interface LicensingValues { hasPlatinumLicense: boolean; hasGoldLicense: boolean; isTrial: boolean; + canManageLicense: boolean; } interface LicensingActions { setLicense(license: ILicense): ILicense; @@ -28,7 +29,7 @@ export const LicensingLogic = kea license, setLicenseSubscription: (licenseSubscription) => licenseSubscription, }, - reducers: { + reducers: ({ props }) => ({ license: [ null, { @@ -41,7 +42,8 @@ export const LicensingLogic = kea licenseSubscription, }, ], - }, + canManageLicense: [props.canManageLicense || false, {}], + }), selectors: { hasPlatinumLicense: [ (selectors) => [selectors.license], @@ -80,6 +82,7 @@ export const LicensingLogic = kea; + canManageLicense: boolean; } export const mountLicensingLogic = (props: LicensingLogicProps) => { LicensingLogic(props); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.test.tsx new file mode 100644 index 0000000000000..1877a4cbd0e42 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues } from '../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton } from '@elastic/eui'; + +import { EuiButtonTo } from '../react_router_helpers'; + +import { ManageLicenseButton } from './'; + +describe('ManageLicenseButton', () => { + describe('when the user can access license management', () => { + it('renders a SPA link to the license management plugin', () => { + setMockValues({ canManageLicense: true }); + const wrapper = shallow(); + + expect(wrapper.find(EuiButtonTo).prop('to')).toEqual( + '/app/management/stack/license_management' + ); + }); + }); + + describe('when the user cannot access license management', () => { + it('renders an external link to our license management documentation', () => { + setMockValues({ canManageLicense: false }); + const wrapper = shallow(); + + expect(wrapper.find(EuiButton).prop('href')).toEqual( + expect.stringContaining('/license-management.html') + ); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.tsx new file mode 100644 index 0000000000000..af3b33e3d7a3d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { EuiButton, EuiButtonProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { docLinks } from '../doc_links'; +import { EuiButtonTo } from '../react_router_helpers'; + +import { LicensingLogic } from './licensing_logic'; + +export const ManageLicenseButton: React.FC = (props) => { + const { canManageLicense } = useValues(LicensingLogic); + + return canManageLicense ? ( + + {i18n.translate('xpack.enterpriseSearch.licenseManagementLink', { + defaultMessage: 'Manage your license', + })} + + ) : ( + + {i18n.translate('xpack.enterpriseSearch.licenseDocumentationLink', { + defaultMessage: 'Learn more about license features', + })} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/test_helpers/get_page_header.tsx b/x-pack/plugins/enterprise_search/public/applications/test_helpers/get_page_header.tsx index 6e89274dca570..a251188b5cd90 100644 --- a/x-pack/plugins/enterprise_search/public/applications/test_helpers/get_page_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/test_helpers/get_page_header.tsx @@ -41,3 +41,9 @@ export const getPageHeaderActions = (wrapper: ShallowWrapper) => {
); }; + +export const getPageHeaderChildren = (wrapper: ShallowWrapper) => { + const children = getPageHeader(wrapper).children || null; + + return shallow(
{children}
); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts index ed5c3f85a888e..7903b4a31c8a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts @@ -15,6 +15,7 @@ export { getPageTitle, getPageDescription, getPageHeaderActions, + getPageHeaderChildren, } from './get_page_header'; // Misc diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx index 3d5d0a8e6f2cf..04b0880a7351c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx @@ -9,6 +9,9 @@ jest.mock('../../../shared/layout', () => ({ ...jest.requireActual('../../../shared/layout'), generateNavLink: jest.fn(({ to }) => ({ href: to })), })); +jest.mock('../../views/content_sources/components/source_sub_nav', () => ({ + useSourceSubNav: () => [], +})); jest.mock('../../views/groups/components/group_sub_nav', () => ({ useGroupSubNav: () => [], })); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index f59679e0ee048..99225bc36e892 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -19,6 +19,7 @@ import { GROUPS_PATH, ORG_SETTINGS_PATH, } from '../../routes'; +import { useSourceSubNav } from '../../views/content_sources/components/source_sub_nav'; import { useGroupSubNav } from '../../views/groups/components/group_sub_nav'; import { useSettingsSubNav } from '../../views/settings/components/settings_sub_nav'; @@ -33,7 +34,7 @@ export const useWorkplaceSearchNav = () => { id: 'sources', name: NAV.SOURCES, ...generateNavLink({ to: SOURCES_PATH }), - items: [], // TODO: Source subnav + items: useSourceSubNav(), }, { id: 'groups', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss index 175f6b9ebca20..3287cb21783cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss @@ -6,18 +6,20 @@ */ .personalDashboardLayout { - $sideBarWidth: $euiSize * 30; - $consoleHeaderHeight: 48px; // NOTE: Keep an eye on this for changes - $pageHeight: calc(100vh - #{$consoleHeaderHeight}); + &__sideBar { + padding: $euiSizeXL $euiSizeXXL $euiSizeXXL; - left: $sideBarWidth; - width: calc(100% - #{$sideBarWidth}); - min-height: $pageHeight; + @include euiBreakpoint('m', 'l') { + min-width: $euiSize * 20; + } + @include euiBreakpoint('xl') { + min-width: $euiSize * 30; + } + } - &__sideBar { - padding: 32px 40px 40px; - width: $sideBarWidth; - margin-left: -$sideBarWidth; - height: $pageHeight; + &__body { + position: relative; + width: 100%; + height: 100%; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx index faeaa7323e93f..6847e91d46f6e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx @@ -5,37 +5,102 @@ * 2.0. */ +import { setMockValues } from '../../../../__mocks__/kea_logic'; +import { mockUseRouteMatch } from '../../../../__mocks__/react_router'; + import React from 'react'; import { shallow } from 'enzyme'; import { EuiCallOut } from '@elastic/eui'; -import { AccountHeader } from '..'; +import { FlashMessages } from '../../../../shared/flash_messages'; +import { SetWorkplaceSearchChrome } from '../../../../shared/kibana_chrome'; +import { Loading } from '../../../../shared/loading'; + +import { AccountHeader, AccountSettingsSidebar, PrivateSourcesSidebar } from '../index'; import { PersonalDashboardLayout } from './personal_dashboard_layout'; describe('PersonalDashboardLayout', () => { const children =

test

; - const sidebar =

test

; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues({ readOnlyMode: false }); + }); it('renders', () => { - const wrapper = shallow( - {children} - ); + const wrapper = shallow({children}); expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(1); - expect(wrapper.find('[data-test-subj="TestSidebar"]')).toHaveLength(1); + expect(wrapper.find('.personalDashboardLayout')).toHaveLength(1); expect(wrapper.find(AccountHeader)).toHaveLength(1); + expect(wrapper.find(FlashMessages)).toHaveLength(1); }); - it('renders callout when in read-only mode', () => { + describe('renders sidebar content based on the route', () => { + it('renders the private sources sidebar on the private sources path', () => { + (mockUseRouteMatch as jest.Mock).mockImplementation((path: string) => path === '/p/sources'); + const wrapper = shallow({children}); + + expect(wrapper.find(PrivateSourcesSidebar)).toHaveLength(1); + }); + + it('renders the account settings sidebar on the account settings path', () => { + (mockUseRouteMatch as jest.Mock).mockImplementation((path: string) => path === '/p/settings'); + const wrapper = shallow({children}); + + expect(wrapper.find(AccountSettingsSidebar)).toHaveLength(1); + }); + + it('does not render a sidebar if not on a valid personal dashboard path', () => { + (mockUseRouteMatch as jest.Mock).mockImplementation((path: string) => path === '/test'); + const wrapper = shallow({children}); + + expect(wrapper.find(AccountSettingsSidebar)).toHaveLength(0); + expect(wrapper.find(PrivateSourcesSidebar)).toHaveLength(0); + }); + }); + + describe('loading state', () => { + it('renders a loading icon in place of children', () => { + const wrapper = shallow( + {children} + ); + + expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(0); + }); + + it('renders children & does not render a loading icon when the page is done loading', () => { + const wrapper = shallow( + {children} + ); + + expect(wrapper.find(Loading)).toHaveLength(0); + expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(1); + }); + }); + + it('sets WS page chrome (primarily document title)', () => { const wrapper = shallow( - + {children} ); + expect(wrapper.find(SetWorkplaceSearchChrome).prop('trail')).toEqual([ + 'Sources', + 'Add source', + 'Gmail', + ]); + }); + + it('renders callout when in read-only mode', () => { + setMockValues({ readOnlyMode: true }); + const wrapper = shallow({children}); + expect(wrapper.find(EuiCallOut)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx index 1ab9e07dfa14d..5b68d661ac5df 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx @@ -6,44 +6,67 @@ */ import React from 'react'; +import { useRouteMatch } from 'react-router-dom'; -import { EuiPage, EuiPageSideBar, EuiPageBody, EuiCallOut } from '@elastic/eui'; +import { useValues } from 'kea'; -import { AccountHeader } from '..'; +import { + EuiPage, + EuiPageSideBar, + EuiPageBody, + EuiPageContentBody, + EuiCallOut, + EuiSpacer, +} from '@elastic/eui'; +import { FlashMessages } from '../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../shared/http'; +import { SetWorkplaceSearchChrome } from '../../../../shared/kibana_chrome'; +import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs'; +import { Loading } from '../../../../shared/loading'; + +import { PERSONAL_SOURCES_PATH, PERSONAL_SETTINGS_PATH } from '../../../routes'; import { PRIVATE_DASHBOARD_READ_ONLY_MODE_WARNING } from '../../../views/content_sources/constants'; +import { AccountHeader, AccountSettingsSidebar, PrivateSourcesSidebar } from '../index'; import './personal_dashboard_layout.scss'; interface LayoutProps { - restrictWidth?: boolean; - readOnlyMode?: boolean; - sidebar: React.ReactNode; + isLoading?: boolean; + pageChrome?: BreadcrumbTrail; } export const PersonalDashboardLayout: React.FC = ({ children, - restrictWidth, - readOnlyMode, - sidebar, + isLoading, + pageChrome, }) => { + const { readOnlyMode } = useValues(HttpLogic); + return ( <> + {pageChrome && } - - - {sidebar} + + + {useRouteMatch(PERSONAL_SOURCES_PATH) && } + {useRouteMatch(PERSONAL_SETTINGS_PATH) && } - - {readOnlyMode && ( - - )} - {children} + + + {readOnlyMode && ( + <> + + + + )} + + {isLoading ? : children} + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx index 387724af970f8..9fa4d4dd1b237 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx @@ -7,17 +7,22 @@ import { setMockValues } from '../../../../__mocks__/kea_logic'; +jest.mock('../../../views/content_sources/components/source_sub_nav', () => ({ + useSourceSubNav: () => [], +})); + import React from 'react'; import { shallow } from 'enzyme'; +import { EuiSideNav } from '@elastic/eui'; + import { PRIVATE_CAN_CREATE_PAGE_TITLE, PRIVATE_VIEW_ONLY_PAGE_TITLE, PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION, PRIVATE_CAN_CREATE_PAGE_DESCRIPTION, } from '../../../constants'; -import { SourceSubNav } from '../../../views/content_sources/components/source_sub_nav'; import { ViewContentHeader } from '../../shared/view_content_header'; @@ -26,6 +31,7 @@ import { PrivateSourcesSidebar } from './private_sources_sidebar'; describe('PrivateSourcesSidebar', () => { const mockValues = { account: { canCreatePersonalSources: true }, + contentSource: {}, }; beforeEach(() => { @@ -36,25 +42,42 @@ describe('PrivateSourcesSidebar', () => { const wrapper = shallow(); expect(wrapper.find(ViewContentHeader)).toHaveLength(1); - expect(wrapper.find(SourceSubNav)).toHaveLength(1); }); - it('uses correct title and description when private sources are enabled', () => { - const wrapper = shallow(); + describe('header text', () => { + it('uses correct title and description when private sources are enabled', () => { + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_CAN_CREATE_PAGE_TITLE); + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + PRIVATE_CAN_CREATE_PAGE_DESCRIPTION + ); + }); - expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_CAN_CREATE_PAGE_TITLE); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - PRIVATE_CAN_CREATE_PAGE_DESCRIPTION - ); + it('uses correct title and description when private sources are disabled', () => { + setMockValues({ ...mockValues, account: { canCreatePersonalSources: false } }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_VIEW_ONLY_PAGE_TITLE); + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION + ); + }); }); - it('uses correct title and description when private sources are disabled', () => { - setMockValues({ account: { canCreatePersonalSources: false } }); - const wrapper = shallow(); + describe('sub nav', () => { + it('renders a side nav when viewing a single source', () => { + setMockValues({ ...mockValues, contentSource: { id: '1', name: 'test source' } }); + const wrapper = shallow(); + + expect(wrapper.find(EuiSideNav)).toHaveLength(1); + }); + + it('does not render a side nav if not on a source page', () => { + setMockValues({ ...mockValues, contentSource: {} }); + const wrapper = shallow(); - expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_VIEW_ONLY_PAGE_TITLE); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION - ); + expect(wrapper.find(EuiSideNav)).toHaveLength(0); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx index 5505ae57b2ad5..36496b83b3123 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx @@ -9,6 +9,8 @@ import React from 'react'; import { useValues } from 'kea'; +import { EuiSideNav } from '@elastic/eui'; + import { AppLogic } from '../../../app_logic'; import { PRIVATE_CAN_CREATE_PAGE_TITLE, @@ -16,7 +18,8 @@ import { PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION, PRIVATE_CAN_CREATE_PAGE_DESCRIPTION, } from '../../../constants'; -import { SourceSubNav } from '../../../views/content_sources/components/source_sub_nav'; +import { useSourceSubNav } from '../../../views/content_sources/components/source_sub_nav'; +import { SourceLogic } from '../../../views/content_sources/source_logic'; import { ViewContentHeader } from '../../shared/view_content_header'; export const PrivateSourcesSidebar = () => { @@ -31,10 +34,17 @@ export const PrivateSourcesSidebar = () => { ? PRIVATE_CAN_CREATE_PAGE_DESCRIPTION : PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION; + const { + contentSource: { id = '', name = '' }, + } = useValues(SourceLogic); + + const navItems = [{ id, name, items: useSourceSubNav() }]; + return ( <> - + {/* @ts-expect-error: TODO, uncomment this once EUI 34.x lands in Kibana & `mobileBreakpoints` is a valid prop */} + {id && } ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index f4278d5083143..8a1e9c0275322 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -19,11 +19,6 @@ import { NotFound } from '../shared/not_found'; import { AppLogic } from './app_logic'; import { WorkplaceSearchNav, WorkplaceSearchHeaderActions } from './components/layout'; -import { - PersonalDashboardLayout, - PrivateSourcesSidebar, - AccountSettingsSidebar, -} from './components/layout'; import { GROUPS_PATH, SETUP_GUIDE_PATH, @@ -34,11 +29,11 @@ import { ROLE_MAPPINGS_PATH, SECURITY_PATH, PERSONAL_SETTINGS_PATH, + PERSONAL_PATH, } from './routes'; import { AccountSettings } from './views/account_settings'; import { SourcesRouter } from './views/content_sources'; import { SourceAdded } from './views/content_sources/components/source_added'; -import { SourceSubNav } from './views/content_sources/components/source_sub_nav'; import { ErrorState } from './views/error_state'; import { GroupsRouter } from './views/groups'; import { Overview } from './views/overview'; @@ -60,9 +55,6 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { const { pathname } = useLocation(); - // We don't want so show the subnavs on the container root pages. - const showSourcesSubnav = pathname !== SOURCES_PATH && pathname !== PERSONAL_SOURCES_PATH; - /** * Personal dashboard urls begin with /p/ * EX: http://localhost:5601/app/enterprise_search/workplace_search/p/sources @@ -95,32 +87,18 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { - - } - > - - - - - } - > - - + + + + + + + + + - } />} - restrictWidth - readOnlyMode={readOnlyMode} - > - - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx index a5a3d6b491bb9..b89a1451f7e57 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx @@ -76,13 +76,13 @@ describe('getReindexJobRoute', () => { it('should format org path', () => { expect(getReindexJobRoute(SOURCE_ID, REINDEX_ID, true)).toEqual( - `/sources/${SOURCE_ID}/schema_errors/${REINDEX_ID}` + `/sources/${SOURCE_ID}/schemas/${REINDEX_ID}` ); }); it('should format user path', () => { expect(getReindexJobRoute(SOURCE_ID, REINDEX_ID, false)).toEqual( - `/p/sources/${SOURCE_ID}/schema_errors/${REINDEX_ID}` + `/p/sources/${SOURCE_ID}/schemas/${REINDEX_ID}` ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 1fe8019c4b364..3c564c1f912ec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -88,7 +88,7 @@ export const SOURCE_CONTENT_PATH = `${SOURCES_PATH}/:sourceId/content`; export const SOURCE_SCHEMAS_PATH = `${SOURCES_PATH}/:sourceId/schemas`; export const SOURCE_DISPLAY_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/display_settings`; export const SOURCE_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/settings`; -export const REINDEX_JOB_PATH = `${SOURCES_PATH}/:sourceId/schema_errors/:activeReindexJobId`; +export const REINDEX_JOB_PATH = `${SOURCE_SCHEMAS_PATH}/:activeReindexJobId`; export const DISPLAY_SETTINGS_SEARCH_RESULT_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/`; export const DISPLAY_SETTINGS_RESULT_DETAIL_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/result_detail`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.test.tsx new file mode 100644 index 0000000000000..5ff80a7683db6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.test.tsx @@ -0,0 +1,57 @@ +/* + * 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 '../../../__mocks__/shallow_useeffect.mock'; +import { mockKibanaValues } from '../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { AccountSettings } from './'; + +describe('AccountSettings', () => { + const { + security: { + authc: { getCurrentUser }, + uiApi: { + components: { getPersonalInfo, getChangePassword }, + }, + }, + } = mockKibanaValues; + + const mockCurrentUser = (user?: unknown) => + (getCurrentUser as jest.Mock).mockReturnValue(Promise.resolve(user)); + + beforeAll(() => { + mockCurrentUser(); + }); + + it('gets the current user on mount', () => { + shallow(); + + expect(getCurrentUser).toHaveBeenCalled(); + }); + + it('does not render if the current user does not exist', async () => { + mockCurrentUser(null); + const wrapper = await shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders the security UI components when the user exists', async () => { + mockCurrentUser({ username: 'mock user' }); + (getPersonalInfo as jest.Mock).mockReturnValue(
); + (getChangePassword as jest.Mock).mockReturnValue(
); + + const wrapper = await shallow(); + + expect(wrapper.childAt(0).dive().find('[data-test-subj="PersonalInfo"]')).toHaveLength(1); + expect(wrapper.childAt(1).dive().find('[data-test-subj="ChangePassword"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx index e28faaeec8993..313d3ffa59d48 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx @@ -11,6 +11,8 @@ import { useValues } from 'kea'; import type { AuthenticatedUser } from '../../../../../../security/public'; import { KibanaLogic } from '../../../shared/kibana/kibana_logic'; +import { PersonalDashboardLayout } from '../../components/layout'; +import { ACCOUNT_SETTINGS_TITLE } from '../../constants'; export const AccountSettings: React.FC = () => { const { security } = useValues(KibanaLogic); @@ -31,9 +33,9 @@ export const AccountSettings: React.FC = () => { } return ( - <> + - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx index 92cbfcf6eeafe..0501509b3a8ef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx @@ -17,7 +17,10 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Loading } from '../../../../../shared/loading'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; import { AddSource } from './add_source'; import { AddSourceSteps } from './add_source_logic'; @@ -68,11 +71,27 @@ describe('AddSourceList', () => { expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); }); - it('handles loading state', () => { - setMockValues({ ...mockValues, dataLoading: true }); + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); + + it('renders a breadcrumb fallback while data is loading', () => { + setMockValues({ ...mockValues, dataLoading: true, sourceConfigData: {} }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('pageChrome')).toEqual(['Sources', 'Add Source', '...']); }); it('renders Config Completed step', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index ee4bcfb9afd34..b0c3ebe64830c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -13,9 +13,12 @@ import { i18n } from '@kbn/i18n'; import { setSuccessMessage } from '../../../../../shared/flash_messages'; import { KibanaLogic } from '../../../../../shared/kibana'; -import { Loading } from '../../../../../shared/loading'; import { AppLogic } from '../../../../app_logic'; -import { CUSTOM_SERVICE_TYPE } from '../../../../constants'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; import { SOURCES_PATH, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { staticSourceData } from '../../source_data'; @@ -71,8 +74,6 @@ export const AddSource: React.FC = (props) => { return resetSourceState; }, []); - if (dataLoading) return ; - const goToConfigurationIntro = () => setAddSourceStep(AddSourceSteps.ConfigIntroStep); const goToSaveConfig = () => setAddSourceStep(AddSourceSteps.SaveConfigStep); const setConfigCompletedStep = () => setAddSourceStep(AddSourceSteps.ConfigCompletedStep); @@ -99,9 +100,10 @@ export const AddSource: React.FC = (props) => { }; const header = ; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( - <> + {addSourceCurrentStep === AddSourceSteps.ConfigIntroStep && ( )} @@ -158,6 +160,6 @@ export const AddSource: React.FC = (props) => { {addSourceCurrentStep === AddSourceSteps.ReauthenticateStep && ( )} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx index 6bf71cd73ec35..b30511f0a6d80 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx @@ -19,7 +19,11 @@ import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiFieldSearch } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; +import { getPageDescription } from '../../../../../test_helpers'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; import { AddSourceList } from './add_source_list'; @@ -54,14 +58,21 @@ describe('AddSourceList', () => { expect(wrapper.find(AvailableSourcesList)).toHaveLength(1); }); - it('returns loading when loading', () => { - setMockValues({ - ...mockValues, - dataLoading: true, + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); }); - const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + it('renders the personal dashboard layout and a header when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + }); }); describe('filters sources', () => { @@ -97,49 +108,51 @@ describe('AddSourceList', () => { }); describe('content headings', () => { - it('should render correct organization heading with sources', () => { - const wrapper = shallow(); - - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_ORG_SOURCE_DESCRIPTION - ); - }); + describe('organization view', () => { + it('should render the correct organization heading with sources', () => { + const wrapper = shallow(); - it('should render correct organization heading without sources', () => { - setMockValues({ - ...mockValues, - contentSources: [], + expect(getPageDescription(wrapper)).toEqual(ADD_SOURCE_ORG_SOURCE_DESCRIPTION); }); - const wrapper = shallow(); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_ORG_SOURCE_DESCRIPTION - ); - }); + it('should render the correct organization heading without sources', () => { + setMockValues({ + ...mockValues, + contentSources: [], + }); + const wrapper = shallow(); - it('should render correct account heading with sources', () => { - const wrapper = shallow(); - setMockValues({ - ...mockValues, - isOrganization: false, + expect(getPageDescription(wrapper)).toEqual( + ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_ORG_SOURCE_DESCRIPTION + ); }); - - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_ORG_SOURCE_DESCRIPTION - ); }); - it('should render correct account heading without sources', () => { - setMockValues({ - ...mockValues, - isOrganization: false, - contentSources: [], + describe('personal dashboard view', () => { + it('should render the correct personal heading with sources', () => { + setMockValues({ + ...mockValues, + isOrganization: false, + }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + ADD_SOURCE_PRIVATE_SOURCE_DESCRIPTION + ); }); - const wrapper = shallow(); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_PRIVATE_SOURCE_DESCRIPTION - ); + it('should render the correct personal heading without sources', () => { + setMockValues({ + ...mockValues, + isOrganization: false, + contentSources: [], + }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_PRIVATE_SOURCE_DESCRIPTION + ); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx index 80d35553bb8bb..a7a64194cb42f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx @@ -19,12 +19,15 @@ import { EuiEmptyPrompt, } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { AppLogic } from '../../../../app_logic'; import noSharedSourcesIcon from '../../../../assets/share_circle.svg'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; import { ContentSection } from '../../../../components/shared/content_section'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; -import { CUSTOM_SERVICE_TYPE } from '../../../../constants'; +import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; import { SourceDataItem } from '../../../../types'; import { SourcesLogic } from '../../sources_logic'; @@ -58,8 +61,6 @@ export const AddSourceList: React.FC = () => { return resetSourcesState; }, []); - if (dataLoading) return ; - const hasSources = contentSources.length > 0; const showConfiguredSourcesList = configuredSources.find( ({ serviceType }) => serviceType !== CUSTOM_SERVICE_TYPE @@ -97,12 +98,22 @@ export const AddSourceList: React.FC = () => { filterConfiguredSources ) as SourceDataItem[]; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + return ( - <> - + + {!isOrganization && ( +
+ +
+ )} {showConfiguredSourcesList || isOrganization ? ( - { )} - +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx index aa5cec385738d..e5714bf4bdfbf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx @@ -16,7 +16,6 @@ import { shallow } from 'enzyme'; import { EuiButton, EuiTabbedContent } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; @@ -57,13 +56,6 @@ describe('DisplaySettings', () => { expect(wrapper.find('form')).toHaveLength(1); }); - it('returns loading when loading', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - describe('tabbed content', () => { const tabs = [ { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx index d923fbe7a1a8e..ae47e20026b68 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx @@ -20,10 +20,10 @@ import { } from '@elastic/eui'; import { clearFlashMessages } from '../../../../../shared/flash_messages'; -import { Loading } from '../../../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; -import { SAVE_BUTTON } from '../../../../constants'; +import { NAV, SAVE_BUTTON } from '../../../../constants'; +import { SourceLayout } from '../source_layout'; import { UNSAVED_MESSAGE, @@ -64,8 +64,6 @@ export const DisplaySettings: React.FC = ({ tabId }) => { return clearFlashMessages; }, []); - if (dataLoading) return ; - const tabs = [ { id: 'search_results', @@ -89,7 +87,11 @@ export const DisplaySettings: React.FC = ({ tabId }) => { }; return ( - <> + = ({ tabId }) => { )} {addFieldModalVisible && } - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts index e8b419a31abb2..38424df724bd4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { DropResult } from 'react-beautiful-dnd'; - import { kea, MakeLogicType } from 'kea'; import { cloneDeep, isEqual, differenceBy } from 'lodash'; +import { DropResult } from '@elastic/eui'; + import { setSuccessMessage, clearFlashMessages, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx index f2cf5f50b813b..d99eac5de74e5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx @@ -5,8 +5,6 @@ * 2.0. */ -import '../../../../__mocks__/shallow_useeffect.mock'; - import { setMockValues } from '../../../../__mocks__/kea_logic'; import { fullContentSources } from '../../../__mocks__/content_sources.mock'; @@ -16,7 +14,6 @@ import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiPanel, EuiTable } from '@elastic/eui'; -import { Loading } from '../../../../shared/loading'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { Overview } from './overview'; @@ -44,13 +41,6 @@ describe('Overview', () => { expect(documentSummary.find('[data-test-subj="DocumentSummaryRow"]')).toHaveLength(1); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders ComponentLoader when loading', () => { setMockValues({ ...mockValues, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index 153df1bc00496..cc890e0f104ac 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -29,7 +29,6 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Loading } from '../../../../shared/loading'; import { EuiPanelTo } from '../../../../shared/react_router_helpers'; import { AppLogic } from '../../../app_logic'; import aclImage from '../../../assets/supports_acl.svg'; @@ -78,8 +77,10 @@ import { } from '../constants'; import { SourceLogic } from '../source_logic'; +import { SourceLayout } from './source_layout'; + export const Overview: React.FC = () => { - const { contentSource, dataLoading } = useValues(SourceLogic); + const { contentSource } = useValues(SourceLogic); const { isOrganization } = useValues(AppLogic); const { @@ -97,8 +98,6 @@ export const Overview: React.FC = () => { isFederatedSource, } = contentSource; - if (dataLoading) return ; - const DocumentSummary = () => { let totalDocuments = 0; const tableContent = summary?.map((item, index) => { @@ -450,8 +449,9 @@ export const Overview: React.FC = () => { ); return ( - <> + + @@ -513,6 +513,6 @@ export const Overview: React.FC = () => { - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx index 178c9125ee437..47859e4e67b17 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx @@ -16,7 +16,6 @@ import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiFieldSearch } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { SchemaAddFieldModal, SchemaErrorsCallout } from '../../../../../shared/schema'; import { Schema } from './schema'; @@ -71,13 +70,6 @@ describe('Schema', () => { expect(wrapper.find(SchemaFieldsTable)).toHaveLength(1); }); - it('returns loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('handles empty state', () => { setMockValues({ ...mockValues, activeSchema: {} }); const wrapper = shallow(); @@ -106,7 +98,7 @@ describe('Schema', () => { expect(wrapper.find(SchemaErrorsCallout)).toHaveLength(1); expect(wrapper.find(SchemaErrorsCallout).prop('viewErrorsPath')).toEqual( - '/sources/123/schema_errors/123' + '/sources/123/schemas/123' ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx index 65ed988f45ff0..a0efebdcb5a48 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx @@ -20,11 +20,12 @@ import { EuiPanel, } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { SchemaAddFieldModal, SchemaErrorsCallout } from '../../../../../shared/schema'; import { AppLogic } from '../../../../app_logic'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; +import { NAV } from '../../../../constants'; import { getReindexJobRoute } from '../../../../routes'; +import { SourceLayout } from '../source_layout'; import { SCHEMA_ADD_FIELD_BUTTON, @@ -65,8 +66,6 @@ export const Schema: React.FC = () => { initializeSchema(); }, []); - if (dataLoading) return ; - const hasSchemaFields = Object.keys(activeSchema).length > 0; const { hasErrors, activeReindexJobId } = mostRecentIndexJob; @@ -77,7 +76,11 @@ export const Schema: React.FC = () => { ); return ( - <> + { closeAddFieldModal={closeAddFieldModal} /> )} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx index e300823aa3ed3..eb07beda73327 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx @@ -12,6 +12,8 @@ import { useActions, useValues } from 'kea'; import { SchemaErrorsAccordion } from '../../../../../shared/schema'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; +import { NAV } from '../../../../constants'; +import { SourceLayout } from '../source_layout'; import { SCHEMA_ERRORS_HEADING } from './constants'; import { SchemaLogic } from './schema_logic'; @@ -30,9 +32,12 @@ export const SchemaChangeErrors: React.FC = () => { }, []); return ( - <> + - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx index 4bcc4b16166d1..9304f0f344a1b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx @@ -25,7 +25,6 @@ import { } from '@elastic/eui'; import { DEFAULT_META } from '../../../../shared/constants'; -import { Loading } from '../../../../shared/loading'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { TablePaginationBar } from '../../../components/shared/table_pagination_bar'; @@ -61,13 +60,6 @@ describe('SourceContent', () => { expect(wrapper.find(EuiTable)).toHaveLength(1); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('returns ComponentLoader when section loading', () => { setMockValues({ ...mockValues, sectionLoading: true }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx index fbafe54df7493..a0e3c28f20eb0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx @@ -31,12 +31,11 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Loading } from '../../../../shared/loading'; import { TruncatedContent } from '../../../../shared/truncate'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { TablePaginationBar } from '../../../components/shared/table_pagination_bar'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; -import { CUSTOM_SERVICE_TYPE } from '../../../constants'; +import { NAV, CUSTOM_SERVICE_TYPE } from '../../../constants'; import { CUSTOM_SOURCE_DOCS_URL } from '../../../routes'; import { SourceContentItem } from '../../../types'; import { @@ -51,6 +50,8 @@ import { } from '../constants'; import { SourceLogic } from '../source_logic'; +import { SourceLayout } from './source_layout'; + const MAX_LENGTH = 28; export const SourceContent: React.FC = () => { @@ -67,7 +68,6 @@ export const SourceContent: React.FC = () => { }, contentItems, contentFilterValue, - dataLoading, sectionLoading, } = useValues(SourceLogic); @@ -75,8 +75,6 @@ export const SourceContent: React.FC = () => { searchContentSourceDocuments(id); }, [contentFilterValue, activePage]); - if (dataLoading) return ; - const showPagination = totalPages > 1; const hasItems = totalItems > 0; const emptyMessage = contentFilterValue @@ -193,7 +191,7 @@ export const SourceContent: React.FC = () => { ); return ( - <> + @@ -219,6 +217,6 @@ export const SourceContent: React.FC = () => { {sectionLoading && } {!sectionLoading && (hasItems ? contentTable : emptyState)} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx new file mode 100644 index 0000000000000..7c7d77ec418e7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx @@ -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 '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues } from '../../../../__mocks__/kea_logic'; +import { contentSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCallOut } from '@elastic/eui'; + +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../../components/layout'; + +import { SourceInfoCard } from './source_info_card'; +import { SourceLayout } from './source_layout'; + +describe('SourceLayout', () => { + const contentSource = contentSources[1]; + const mockValues = { + contentSource, + dataLoading: false, + isOrganization: true, + }; + + beforeEach(() => { + setMockValues({ ...mockValues }); + }); + + it('renders', () => { + const wrapper = shallow( + +
+ + ); + + expect(wrapper.find(SourceInfoCard)).toHaveLength(1); + expect(wrapper.find('.testChild')).toHaveLength(1); + }); + + it('renders the default Workplace Search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders a personal dashboard layout when not on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + + it('passes any page template props to the underlying page template', () => { + const wrapper = shallow(); + + expect(wrapper.find(WorkplaceSearchPageTemplate).prop('pageViewTelemetry')).toEqual('test'); + }); + + it('handles breadcrumbs while loading', () => { + setMockValues({ + ...mockValues, + contentSource: {}, + dataLoading: true, + }); + const wrapper = shallow(); + + expect(wrapper.prop('pageChrome')).toEqual(['Sources', '...']); + }); + + it('renders a callout when the source is not supported by the current license', () => { + setMockValues({ ...mockValues, contentSource: { supportedByLicense: false } }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx new file mode 100644 index 0000000000000..446e93e0c61f3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx @@ -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 React from 'react'; + +import { useValues } from 'kea'; +import moment from 'moment'; + +import { EuiButton, EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; + +import { PageTemplateProps } from '../../../../shared/layout'; +import { AppLogic } from '../../../app_logic'; +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../../components/layout'; +import { NAV } from '../../../constants'; +import { ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../routes'; + +import { + SOURCE_DISABLED_CALLOUT_TITLE, + SOURCE_DISABLED_CALLOUT_DESCRIPTION, + SOURCE_DISABLED_CALLOUT_BUTTON, +} from '../constants'; +import { SourceLogic } from '../source_logic'; + +import { SourceInfoCard } from './source_info_card'; + +export const SourceLayout: React.FC = ({ + children, + pageChrome = [], + ...props +}) => { + const { contentSource, dataLoading } = useValues(SourceLogic); + const { isOrganization } = useValues(AppLogic); + + const { + name, + createdAt, + serviceType, + serviceName, + isFederatedSource, + supportedByLicense, + } = contentSource; + + const pageHeader = ( + <> + + + + ); + + const callout = ( + <> + +

{SOURCE_DISABLED_CALLOUT_DESCRIPTION}

+ + {SOURCE_DISABLED_CALLOUT_BUTTON} + +
+ + + ); + + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + {!supportedByLicense && callout} + {pageHeader} + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index aa6cbf3cf6574..667e7fd4dbfb4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -26,6 +26,8 @@ import { AppLogic } from '../../../app_logic'; import { ContentSection } from '../../../components/shared/content_section'; import { SourceConfigFields } from '../../../components/shared/source_config_fields'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { NAV } from '../../../constants'; + import { CANCEL_BUTTON, OK_BUTTON, @@ -52,6 +54,8 @@ import { import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; +import { SourceLayout } from './source_layout'; + export const SourceSettings: React.FC = () => { const { updateContentSource, removeContentSource } = useActions(SourceLogic); const { getSourceConfigData } = useActions(AddSourceLogic); @@ -128,7 +132,7 @@ export const SourceSettings: React.FC = () => { ); return ( - <> +
@@ -197,6 +201,6 @@ export const SourceSettings: React.FC = () => { {confirmModalVisible && confirmModal} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx index 25c389419d731..7f07c59587f96 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx @@ -7,34 +7,92 @@ import { setMockValues } from '../../../../__mocks__/kea_logic'; -import React from 'react'; +jest.mock('../../../../shared/layout', () => ({ + generateNavLink: jest.fn(({ to }) => ({ href: to })), +})); -import { shallow } from 'enzyme'; +import { useSourceSubNav } from './source_sub_nav'; -import { SideNavLink } from '../../../../shared/layout'; -import { CUSTOM_SERVICE_TYPE } from '../../../constants'; +describe('useSourceSubNav', () => { + it('returns undefined when no content source id present', () => { + setMockValues({ contentSource: {} }); -import { SourceSubNav } from './source_sub_nav'; + expect(useSourceSubNav()).toEqual(undefined); + }); -describe('SourceSubNav', () => { - it('renders empty when no group id present', () => { - setMockValues({ contentSource: {} }); - const wrapper = shallow(); + it('returns EUI nav items', () => { + setMockValues({ isOrganization: true, contentSource: { id: '1' } }); - expect(wrapper.find(SideNavLink)).toHaveLength(0); + expect(useSourceSubNav()).toEqual([ + { + id: 'sourceOverview', + name: 'Overview', + href: '/sources/1', + }, + { + id: 'sourceContent', + name: 'Content', + href: '/sources/1/content', + }, + { + id: 'sourceSettings', + name: 'Settings', + href: '/sources/1/settings', + }, + ]); }); - it('renders nav items', () => { - setMockValues({ contentSource: { id: '1' } }); - const wrapper = shallow(); + it('returns extra nav items for custom sources', () => { + setMockValues({ isOrganization: true, contentSource: { id: '2', serviceType: 'custom' } }); - expect(wrapper.find(SideNavLink)).toHaveLength(3); + expect(useSourceSubNav()).toEqual([ + { + id: 'sourceOverview', + name: 'Overview', + href: '/sources/2', + }, + { + id: 'sourceContent', + name: 'Content', + href: '/sources/2/content', + }, + { + id: 'sourceSchema', + name: 'Schema', + href: '/sources/2/schemas', + }, + { + id: 'sourceDisplaySettings', + name: 'Display Settings', + href: '/sources/2/display_settings', + }, + { + id: 'sourceSettings', + name: 'Settings', + href: '/sources/2/settings', + }, + ]); }); - it('renders custom source nav items', () => { - setMockValues({ contentSource: { id: '1', serviceType: CUSTOM_SERVICE_TYPE } }); - const wrapper = shallow(); + it('returns nav links to personal dashboard when not on an organization page', () => { + setMockValues({ isOrganization: false, contentSource: { id: '3' } }); - expect(wrapper.find(SideNavLink)).toHaveLength(5); + expect(useSourceSubNav()).toEqual([ + { + id: 'sourceOverview', + name: 'Overview', + href: '/p/sources/3', + }, + { + id: 'sourceContent', + name: 'Content', + href: '/p/sources/3/content', + }, + { + id: 'sourceSettings', + name: 'Settings', + href: '/p/sources/3/settings', + }, + ]); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx index 12e1506ec6efd..6b595a06f0404 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import React from 'react'; - import { useValues } from 'kea'; -import { SideNavLink } from '../../../../shared/layout'; +import { EuiSideNavItemType } from '@elastic/eui'; + +import { generateNavLink } from '../../../../shared/layout'; import { AppLogic } from '../../../app_logic'; import { NAV, CUSTOM_SERVICE_TYPE } from '../../../constants'; import { @@ -22,40 +22,52 @@ import { } from '../../../routes'; import { SourceLogic } from '../source_logic'; -export const SourceSubNav: React.FC = () => { +export const useSourceSubNav = () => { const { isOrganization } = useValues(AppLogic); const { contentSource: { id, serviceType }, } = useValues(SourceLogic); - if (!id) return null; + if (!id) return undefined; + + const navItems: Array> = [ + { + id: 'sourceOverview', + name: NAV.OVERVIEW, + ...generateNavLink({ to: getContentSourcePath(SOURCE_DETAILS_PATH, id, isOrganization) }), + }, + { + id: 'sourceContent', + name: NAV.CONTENT, + ...generateNavLink({ to: getContentSourcePath(SOURCE_CONTENT_PATH, id, isOrganization) }), + }, + ]; const isCustom = serviceType === CUSTOM_SERVICE_TYPE; + if (isCustom) { + navItems.push({ + id: 'sourceSchema', + name: NAV.SCHEMA, + ...generateNavLink({ + to: getContentSourcePath(SOURCE_SCHEMAS_PATH, id, isOrganization), + shouldShowActiveForSubroutes: true, + }), + }); + navItems.push({ + id: 'sourceDisplaySettings', + name: NAV.DISPLAY_SETTINGS, + ...generateNavLink({ + to: getContentSourcePath(SOURCE_DISPLAY_SETTINGS_PATH, id, isOrganization), + shouldShowActiveForSubroutes: true, + }), + }); + } + + navItems.push({ + id: 'sourceSettings', + name: NAV.SETTINGS, + ...generateNavLink({ to: getContentSourcePath(SOURCE_SETTINGS_PATH, id, isOrganization) }), + }); - return ( -
- - {NAV.OVERVIEW} - - - {NAV.CONTENT} - - {isCustom && ( - <> - - {NAV.SCHEMA} - - - {NAV.DISPLAY_SETTINGS} - - - )} - - {NAV.SETTINGS} - -
- ); + return navItems; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx index 9df91406c4b7b..2317c84af2432 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx @@ -10,14 +10,10 @@ import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; import { contentSources } from '../../__mocks__/content_sources.mock'; import React from 'react'; -import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { Loading } from '../../../shared/loading'; import { SourcesTable } from '../../components/shared/sources_table'; -import { ViewContentHeader } from '../../components/shared/view_content_header'; -import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; import { OrganizationSources } from './organization_sources'; @@ -42,20 +38,12 @@ describe('OrganizationSources', () => { const wrapper = shallow(); expect(wrapper.find(SourcesTable)).toHaveLength(1); - expect(wrapper.find(ViewContentHeader)).toHaveLength(1); }); - it('returns loading when loading', () => { + it('does not render a page header when data is loading (to prevent a jump after redirect)', () => { setMockValues({ ...mockValues, dataLoading: true }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); - }); - - it('returns redirect when no sources', () => { - setMockValues({ ...mockValues, contentSources: [] }); - const wrapper = shallow(); - - expect(wrapper.find(Redirect).prop('to')).toEqual(getSourcesPath(ADD_SOURCE_PATH, true)); + expect(wrapper.prop('pageHeader')).toBeUndefined(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx index 4559003b4597f..a4273ae2ae6a2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx @@ -6,16 +6,15 @@ */ import React, { useEffect } from 'react'; -import { Link, Redirect } from 'react-router-dom'; +import { Redirect } from 'react-router-dom'; import { useActions, useValues } from 'kea'; -import { EuiButton } from '@elastic/eui'; - -import { Loading } from '../../../shared/loading'; +import { EuiButtonTo } from '../../../shared/react_router_helpers'; +import { WorkplaceSearchPageTemplate } from '../../components/layout'; import { ContentSection } from '../../components/shared/content_section'; import { SourcesTable } from '../../components/shared/sources_table'; -import { ViewContentHeader } from '../../components/shared/view_content_header'; +import { NAV } from '../../constants'; import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; import { @@ -36,33 +35,41 @@ export const OrganizationSources: React.FC = () => { const { dataLoading, contentSources } = useValues(SourcesLogic); - if (dataLoading) return ; - - if (contentSources.length === 0) return ; - return ( - - - - {ORG_SOURCES_LINK} - - - } - description={ORG_SOURCES_HEADER_DESCRIPTION} - alignItems="flexStart" - /> - - - - - + + {ORG_SOURCES_LINK} + , + ], + } + } + isLoading={dataLoading} + isEmptyState={!contentSources.length} + emptyState={} + > + + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx index 08f560c984344..e2b0dfba1fa97 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx @@ -15,7 +15,6 @@ import { shallow } from 'enzyme'; import { EuiCallOut, EuiEmptyPrompt } from '@elastic/eui'; -import { Loading } from '../../../shared/loading'; import { ContentSection } from '../../components/shared/content_section'; import { SourcesTable } from '../../components/shared/sources_table'; @@ -43,13 +42,6 @@ describe('PrivateSources', () => { expect(wrapper.find(SourcesView)).toHaveLength(1); }); - it('renders Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders only shared sources section when canCreatePersonalSources is false', () => { setMockValues({ ...mockValues }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx index 128c65eeb95da..693c1e8bd5e40 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx @@ -13,12 +13,13 @@ import { EuiCallOut, EuiEmptyPrompt, EuiSpacer, EuiPanel } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { LicensingLogic } from '../../../shared/licensing'; -import { Loading } from '../../../shared/loading'; import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { AppLogic } from '../../app_logic'; import noSharedSourcesIcon from '../../assets/share_circle.svg'; +import { PersonalDashboardLayout } from '../../components/layout'; import { ContentSection } from '../../components/shared/content_section'; import { SourcesTable } from '../../components/shared/sources_table'; +import { NAV } from '../../constants'; import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; import { toSentenceSerial } from '../../utils'; @@ -53,8 +54,6 @@ export const PrivateSources: React.FC = () => { account: { canCreatePersonalSources, groups }, } = useValues(AppLogic); - if (dataLoading) return ; - const hasConfiguredConnectors = serviceTypes.some(({ configured }) => configured); const canAddSources = canCreatePersonalSources && hasConfiguredConnectors; const hasPrivateSources = privateContentSources?.length > 0; @@ -144,10 +143,12 @@ export const PrivateSources: React.FC = () => { ); return ( - - {hasPrivateSources && !hasPlatinumLicense && licenseCallout} - {canCreatePersonalSources && privateSourcesSection} - {sharedSourcesSection} - + + + {hasPrivateSources && !hasPlatinumLicense && licenseCallout} + {canCreatePersonalSources && privateSourcesSection} + {sharedSourcesSection} + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx index 783fc434fe8e5..afe0d1f89faea 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx @@ -5,21 +5,17 @@ * 2.0. */ -import '../../../__mocks__/shallow_useeffect.mock'; - import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; -import { mockLocation, mockUseParams } from '../../../__mocks__/react_router'; +import { mockUseParams } from '../../../__mocks__/react_router'; import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; import { contentSources } from '../../__mocks__/content_sources.mock'; import React from 'react'; -import { Route, Switch } from 'react-router-dom'; +import { Route } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; -import { NAV } from '../../constants'; +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../components/layout'; import { DisplaySettingsRouter } from './components/display_settings'; import { Overview } from './components/overview'; @@ -37,6 +33,7 @@ describe('SourceRouter', () => { const mockValues = { contentSource, dataLoading: false, + isOrganization: true, }; beforeEach(() => { @@ -50,11 +47,41 @@ describe('SourceRouter', () => { })); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); + describe('mount/unmount events', () => { + it('fetches & initializes source data on mount', () => { + shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(initializeSource).toHaveBeenCalledWith(contentSource.id); + }); + + it('resets state on unmount', () => { + shallow(); + unmountHandler(); + + expect(resetSourceState).toHaveBeenCalled(); + }); + }); + + describe('loading state when fetching source data', () => { + // NOTE: The early page isLoading returns are required to prevent a flash of a completely empty + // page (instead of preserving the layout/side nav while loading). We also cannot let the code + // fall through to the router because some routes are conditionally rendered based on isCustomSource. + + it('returns an empty loading Workplace Search page on organization views', () => { + setMockValues({ ...mockValues, dataLoading: true, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + expect(wrapper.prop('isLoading')).toEqual(true); + }); + + it('returns an empty loading personal dashboard page when not on an organization view', () => { + setMockValues({ ...mockValues, dataLoading: true, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + expect(wrapper.prop('isLoading')).toEqual(true); + }); }); it('renders source routes (standard)', () => { @@ -63,7 +90,6 @@ describe('SourceRouter', () => { expect(wrapper.find(Overview)).toHaveLength(1); expect(wrapper.find(SourceSettings)).toHaveLength(1); expect(wrapper.find(SourceContent)).toHaveLength(1); - expect(wrapper.find(Switch)).toHaveLength(1); expect(wrapper.find(Route)).toHaveLength(3); }); @@ -76,55 +102,4 @@ describe('SourceRouter', () => { expect(wrapper.find(SchemaChangeErrors)).toHaveLength(1); expect(wrapper.find(Route)).toHaveLength(6); }); - - it('handles breadcrumbs while loading (standard)', () => { - setMockValues({ - ...mockValues, - contentSource: {}, - }); - - const loadingBreadcrumbs = ['Sources', '...']; - - const wrapper = shallow(); - - const overviewBreadCrumb = wrapper.find(SetPageChrome).at(0); - const contentBreadCrumb = wrapper.find(SetPageChrome).at(1); - const settingsBreadCrumb = wrapper.find(SetPageChrome).at(2); - - expect(overviewBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs]); - expect(contentBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.CONTENT]); - expect(settingsBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SETTINGS]); - }); - - it('handles breadcrumbs while loading (custom)', () => { - setMockValues({ - ...mockValues, - contentSource: { serviceType: 'custom' }, - }); - - const loadingBreadcrumbs = ['Sources', '...']; - - const wrapper = shallow(); - - const schemaBreadCrumb = wrapper.find(SetPageChrome).at(2); - const schemaErrorsBreadCrumb = wrapper.find(SetPageChrome).at(3); - const displaySettingsBreadCrumb = wrapper.find(SetPageChrome).at(4); - - expect(schemaBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SCHEMA]); - expect(schemaErrorsBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SCHEMA]); - expect(displaySettingsBreadCrumb.prop('trail')).toEqual([ - ...loadingBreadcrumbs, - NAV.DISPLAY_SETTINGS, - ]); - }); - - describe('reset state', () => { - it('resets state when leaving source tree', () => { - mockLocation.pathname = '/home'; - shallow(); - unmountHandler(); - - expect(resetSourceState).toHaveBeenCalled(); - }); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx index d5d6c8e541e4f..bf68a60757c0d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx @@ -10,18 +10,11 @@ import React, { useEffect } from 'react'; import { Route, Switch, useLocation, useParams } from 'react-router-dom'; import { useActions, useValues } from 'kea'; -import moment from 'moment'; -import { EuiButton, EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; - -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; -import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; -import { NAV } from '../../constants'; +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../components/layout'; import { CUSTOM_SERVICE_TYPE } from '../../constants'; import { - ENT_SEARCH_LICENSE_MANAGEMENT, REINDEX_JOB_PATH, SOURCE_DETAILS_PATH, SOURCE_CONTENT_PATH, @@ -37,13 +30,7 @@ import { Overview } from './components/overview'; import { Schema } from './components/schema'; import { SchemaChangeErrors } from './components/schema/schema_change_errors'; import { SourceContent } from './components/source_content'; -import { SourceInfoCard } from './components/source_info_card'; import { SourceSettings } from './components/source_settings'; -import { - SOURCE_DISABLED_CALLOUT_TITLE, - SOURCE_DISABLED_CALLOUT_DESCRIPTION, - SOURCE_DISABLED_CALLOUT_BUTTON, -} from './constants'; import { SourceLogic } from './source_logic'; export const SourceRouter: React.FC = () => { @@ -61,84 +48,43 @@ export const SourceRouter: React.FC = () => { return resetSourceState; }, []); - if (dataLoading) return ; + if (dataLoading) { + return isOrganization ? ( + + ) : ( + + ); + } - const { - name, - createdAt, - serviceType, - serviceName, - isFederatedSource, - supportedByLicense, - } = contentSource; + const { serviceType } = contentSource; const isCustomSource = serviceType === CUSTOM_SERVICE_TYPE; - const pageHeader = ( - <> - - - - ); - - const callout = ( - <> - -

{SOURCE_DISABLED_CALLOUT_DESCRIPTION}

- - {SOURCE_DISABLED_CALLOUT_BUTTON} - -
- - - ); - return ( - <> - {!supportedByLicense && callout} - {pageHeader} - - - - - + + + + + + + + {isCustomSource && ( + + - - - - + )} + {isCustomSource && ( + + - {isCustomSource && ( - - - - - - )} - {isCustomSource && ( - - - - - - )} - {isCustomSource && ( - - - - - - )} - - - - + )} + {isCustomSource && ( + + - - + )} + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index 84bff65e62cef..2abdba07b5c88 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -11,12 +11,8 @@ import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; import { Location } from 'history'; import { useActions, useValues } from 'kea'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { LicensingLogic } from '../../../shared/licensing'; -import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; -import { NAV } from '../../constants'; import { ADD_SOURCE_PATH, SOURCE_DETAILS_PATH, @@ -52,71 +48,53 @@ export const SourcesRouter: React.FC = () => { }, [pathname]); return ( - <> - - - - - - + + + + + + + + {staticSourceData.map(({ addPath, accountContextOnly }, i) => ( + + {!hasPlatinumLicense && accountContextOnly ? ( + + ) : ( + + )} - - - - + ))} + {staticSourceData.map(({ addPath }, i) => ( + + - {staticSourceData.map(({ addPath, accountContextOnly, name }, i) => ( - - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ))} - {staticSourceData.map(({ addPath, name }, i) => ( - - - - - ))} - {staticSourceData.map(({ addPath, name }, i) => ( - - - - - ))} - {staticSourceData.map(({ addPath, name, configuration: { needsConfiguration } }, i) => { - if (needsConfiguration) - return ( - - - - - ); - })} - {canCreatePersonalSources ? ( - - - - - - ) : ( - - )} - - - + ))} + {staticSourceData.map(({ addPath }, i) => ( + + - - + ))} + {staticSourceData.map(({ addPath, configuration: { needsConfiguration } }, i) => { + if (needsConfiguration) + return ( + + + + ); + })} + {canCreatePersonalSources ? ( + + - - + ) : ( + + )} + + + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx index cf23470e8155e..7bd40d6f04a56 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx @@ -25,6 +25,13 @@ describe('Overview', () => { expect(mockActions.initializeOverview).toHaveBeenCalled(); }); + it('does not render a page header when data is loading (to prevent a jump between non/onboarding headers)', () => { + setMockValues({ dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.prop('pageHeader')).toBeUndefined(); + }); + it('renders onboarding state', () => { setMockValues({ dataLoading: false }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx index 0049c5b732d3d..c51fdb64b8f26 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx @@ -53,17 +53,15 @@ export const Overview: React.FC = () => { const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName; - const headerTitle = dataLoading || hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; - const headerDescription = - dataLoading || hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; + const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; + const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx index b32e3af021827..35619d2b2d560 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -40,6 +40,13 @@ describe('SourceConfig', () => { expect(wrapper.find(EuiConfirmModal)).toHaveLength(1); }); + it('renders a breadcrumb fallback while data is loading', () => { + setMockValues({ dataLoading: true, sourceConfigData: {} }); + const wrapper = shallow(); + + expect(wrapper.prop('pageChrome')).toEqual(['Settings', 'Content source connectors', '...']); + }); + it('handles delete click', () => { const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index f1dfda78ee13f..c2a0b60e1eca3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -47,7 +47,7 @@ export const SourceConfig: React.FC = ({ sourceIndex }) => { return ( = ({ isEditing = false, onDelete = () => {}, }) => { + const { docLinks } = useStartServices(); const [touchedFields, setTouchedFields] = useState<{ [key: string]: boolean }>({}); const fields: Array<{ name: 'name' | 'description' | 'namespace'; @@ -174,10 +176,7 @@ export const AgentPolicyForm: React.FunctionComponent = ({ defaultMessage="Namespaces are a user-configurable arbitrary grouping that makes it easier to search for data and manage user permissions. A policy namespace is used to name its integration's data streams. {fleetUserGuide}." values={{ fleetUserGuide: ( - + {i18n.translate( 'xpack.fleet.agentPolicyForm.nameSpaceFieldDescription.fleetUserGuideLabel', { defaultMessage: 'Learn more' } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx index 75fc06c1a4494..b3b0d6ed51cb4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx @@ -19,10 +19,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, + EuiLink, } from '@elastic/eui'; import type { EuiStepProps } from '@elastic/eui/src/components/steps/step'; import type { ApplicationStart } from 'kibana/public'; +import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; import type { AgentPolicy, PackageInfo, @@ -60,7 +62,7 @@ const StepsWithLessPadding = styled(EuiSteps)` `; const CustomEuiBottomBar = styled(EuiBottomBar)` - // Set a relatively _low_ z-index value here to account for EuiComboBox popover that might appear under the bottom bar + /* A relatively _low_ z-index value here to account for EuiComboBox popover that might appear under the bottom bar */ z-index: 50; `; @@ -84,11 +86,26 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { const history = useHistory(); const handleNavigateTo = useNavigateToCallback(); const routeState = useIntraAppState(); - const from: CreatePackagePolicyFrom = 'policyId' in params ? 'policy' : 'package'; const { search } = useLocation(); const queryParams = useMemo(() => new URLSearchParams(search), [search]); - const policyId = useMemo(() => queryParams.get('policyId') ?? undefined, [queryParams]); + const queryParamsPolicyId = useMemo(() => queryParams.get('policyId') ?? undefined, [ + queryParams, + ]); + + /** + * Please note: policyId can come from one of two sources. The URL param (in the URL path) or + * in the query params (?policyId=foo). + * + * Either way, we take this as an indication that a user is "coming from" the fleet policy UI + * since we link them out to packages (a.k.a. integrations) UI when choosing a new package. It is + * no longer possible to choose a package directly in the create package form. + * + * We may want to deprecate the ability to pass in policyId from URL params since there is no package + * creation possible if a user has not chosen one from the packages UI. + */ + const from: CreatePackagePolicyFrom = + 'policyId' in params || queryParamsPolicyId ? 'policy' : 'package'; // Agent policy and package info states const [agentPolicy, setAgentPolicy] = useState(); @@ -280,6 +297,13 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { ); } + const fromPolicyWithoutAgentsAssigned = from === 'policy' && agentPolicy && agentCount === 0; + + const fromPackageWithoutAgentsAssigned = + from === 'package' && packageInfo && agentPolicy && agentCount === 0; + + const hasAgentsAssigned = agentCount && agentPolicy; + notifications.toasts.addSuccess({ title: i18n.translate('xpack.fleet.createPackagePolicy.addedNotificationTitle', { defaultMessage: `'{packagePolicyName}' integration added.`, @@ -287,22 +311,47 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { packagePolicyName: packagePolicy.name, }, }), - text: - agentCount && agentPolicy - ? i18n.translate('xpack.fleet.createPackagePolicy.addedNotificationMessage', { - defaultMessage: `Fleet will deploy updates to all agents that use the '{agentPolicyName}' policy.`, - values: { - agentPolicyName: agentPolicy.name, - }, - }) - : (params as AddToPolicyParams)?.policyId && agentPolicy && agentCount === 0 - ? i18n.translate('xpack.fleet.createPackagePolicy.addAgentNextNotification', { + text: fromPolicyWithoutAgentsAssigned + ? i18n.translate( + 'xpack.fleet.createPackagePolicy.policyContextAddAgentNextNotificationMessage', + { defaultMessage: `The policy has been updated. Add an agent to the '{agentPolicyName}' policy to deploy this policy.`, values: { - agentPolicyName: agentPolicy.name, + agentPolicyName: agentPolicy!.name, }, - }) - : undefined, + } + ) + : fromPackageWithoutAgentsAssigned + ? toMountPoint( + // To render the link below we need to mount this JSX in the success toast + + {i18n.translate( + 'xpack.fleet.createPackagePolicy.integrationsContextAddAgentLinkMessage', + { defaultMessage: 'add an agent' } + )} + + ), + }} + /> + ) + : hasAgentsAssigned + ? i18n.translate('xpack.fleet.createPackagePolicy.addedNotificationMessage', { + defaultMessage: `Fleet will deploy updates to all agents that use the '{agentPolicyName}' policy.`, + values: { + agentPolicyName: agentPolicy!.name, + }, + }) + : undefined, 'data-test-subj': 'packagePolicyCreateSuccessToast', }); } else { @@ -312,6 +361,9 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { setFormState('VALID'); } }, [ + getHref, + from, + packageInfo, agentCount, agentPolicy, formState, @@ -353,13 +405,13 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { ), - [params, updatePackageInfo, agentPolicy, updateAgentPolicy, policyId] + [params, updatePackageInfo, agentPolicy, updateAgentPolicy, queryParamsPolicyId] ); const ExtensionView = useUIExtension(packagePolicy.package?.name ?? '', 'package-policy-create'); @@ -455,7 +507,8 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { )} - + + diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx index 7444bed6ed3fd..c276e67cabbff 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx @@ -29,6 +29,7 @@ import type { } from '../../../types'; import { packageToPackagePolicy, pkgKeyFromPackageInfo } from '../../../services'; import { Loading } from '../../../components'; +import { useStartServices } from '../../../hooks'; import { isAdvancedVar } from './services'; import type { PackagePolicyValidationResults } from './services'; @@ -52,6 +53,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ validationResults, submitAttempted, }) => { + const { docLinks } = useStartServices(); // Form show/hide states const [isShowingAdvanced, setIsShowingAdvanced] = useState(false); @@ -167,10 +169,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ defaultMessage="Change the default namespace inherited from the selected Agent policy. This setting changes the name of the integration's data stream. {learnMore}." values={{ learnMore: ( - + {i18n.translate( 'xpack.fleet.createPackagePolicy.stepConfigure.packagePolicyNamespaceHelpLearnMoreLabel', { defaultMessage: 'Learn more' } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/no_package_policies.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/no_package_policies.tsx index 54adbd78ab75a..39340a21d349b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/no_package_policies.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/no_package_policies.tsx @@ -9,10 +9,11 @@ import React, { memo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -import { useCapabilities, useLink } from '../../../../../hooks'; +import { useCapabilities, useStartServices } from '../../../../../hooks'; +import { pagePathGetters, INTEGRATIONS_PLUGIN_ID } from '../../../../../constants'; export const NoPackagePolicies = memo<{ policyId: string }>(({ policyId }) => { - const { getHref } = useLink(); + const { application } = useStartServices(); const hasWriteCapabilities = useCapabilities().write; return ( @@ -36,7 +37,12 @@ export const NoPackagePolicies = memo<{ policyId: string }>(({ policyId }) => { + application.navigateToApp(INTEGRATIONS_PLUGIN_ID, { + path: `#${pagePathGetters.integrations_all()[1]}`, + state: { forAgentPolicyId: policyId }, + }) + } > = ({ isMissing, @@ -50,6 +51,8 @@ export const RequirementItem: React.FunctionComponent<{ isMissing: boolean }> = export const MissingESRequirementsPage: React.FunctionComponent<{ missingRequirements: GetFleetStatusResponse['missing_requirements']; }> = ({ missingRequirements }) => { + const { docLinks } = useStartServices(); + return ( @@ -79,7 +82,7 @@ export const MissingESRequirementsPage: React.FunctionComponent<{ values={{ esSecurityLink: ( @@ -104,7 +107,7 @@ export const MissingESRequirementsPage: React.FunctionComponent<{ true: true, apiKeyLink: ( @@ -128,11 +131,7 @@ xpack.security.authc.api_key.enabled: true`} defaultMessage="For more information, read our {link} guide." values={{ link: ( - + void; }): EuiStepProps => { + const { docLinks } = useStartServices(); + return { title: i18n.translate('xpack.fleet.fleetServerSetup.stepInstallAgentTitle', { defaultMessage: 'Start Fleet Server', @@ -147,7 +149,11 @@ export const FleetServerCommandStep = ({ defaultMessage="From the agent directory, copy and run the appropriate quick start command to start an Elastic Agent as a Fleet Server using the generated token and a self-signed certificate. See the {userGuideLink} for instructions on using your own certificates for production deployment. All commands require administrator privileges." values={{ userGuideLink: ( - + + { platform, setPlatform, } = useFleetServerInstructions(); + const { docLinks } = useStartServices(); return ( @@ -304,7 +307,11 @@ const OnPremInstructions: React.FC = () => { defaultMessage="A Fleet Server is required before you can enroll agents with Fleet. See the {userGuideLink} for more information." values={{ userGuideLink: ( - + { }; const CloudInstructions: React.FC<{ deploymentUrl: string }> = ({ deploymentUrl }) => { + const { docLinks } = useStartServices(); + return ( = ({ deploymentUrl defaultMessage="A Fleet Server is required before you can enroll agents with Fleet. You can add one to your deployment by enabling APM & Fleet. For more information see the {link}" values={{ link: ( - + = ({ onClose }) => { const { getAssetsPath } = useLink(); - const { notifications, cloud } = useStartServices(); + const { notifications, cloud, docLinks } = useStartServices(); const isCloud = !!cloud?.cloudId; @@ -163,7 +163,11 @@ export const FleetServerUpgradeModal: React.FunctionComponent = ({ onClos ), link: ( - + { + const { toAssets } = useLinks(); + const theme = useTheme() as EuiTheme; + const IS_DARK_THEME = theme.darkMode; + + return ( + + ); +}); + export const DefaultLayout: React.FunctionComponent = memo(({ section, children }) => { const { getHref } = useLink(); @@ -27,11 +57,29 @@ export const DefaultLayout: React.FunctionComponent = memo(({ section, ch } leftColumn={ - -

- {' '} -

-
+ + +

+ +

+
+ + + + + +

+ +

+
+
+
} tabs={[ { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx new file mode 100644 index 0000000000000..e6dce1bc51367 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import { Redirect } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiSpacer } from '@elastic/eui'; + +import { Loading, Error } from '../../../../../components'; + +import type { PackageInfo } from '../../../../../types'; +import { InstallStatus } from '../../../../../types'; + +import { useGetPackageInstallStatus, useLink, useStartServices } from '../../../../../hooks'; + +import type { AssetSavedObject } from './types'; +import { allowedAssetTypes } from './constants'; +import { AssetsAccordion } from './assets_accordion'; + +interface AssetsPanelProps { + packageInfo: PackageInfo; +} + +export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => { + const { name, version } = packageInfo; + const { + savedObjects: { client: savedObjectsClient }, + } = useStartServices(); + + const { getPath } = useLink(); + const getPackageInstallStatus = useGetPackageInstallStatus(); + const packageInstallStatus = getPackageInstallStatus(packageInfo.name); + + const [assetSavedObjects, setAssetsSavedObjects] = useState(); + const [fetchError, setFetchError] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchAssetSavedObjects = async () => { + if ('savedObject' in packageInfo) { + const { + savedObject: { attributes: packageAttributes }, + } = packageInfo; + + if ( + !packageAttributes.installed_kibana || + packageAttributes.installed_kibana.length === 0 + ) { + setIsLoading(false); + return; + } + + try { + const objectsToGet = packageAttributes.installed_kibana.map(({ id, type }) => ({ + id, + type, + })); + const { savedObjects } = await savedObjectsClient.bulkGet(objectsToGet); + setAssetsSavedObjects(savedObjects as AssetSavedObject[]); + } catch (e) { + setFetchError(e); + } finally { + setIsLoading(false); + } + } else { + setIsLoading(false); + } + }; + fetchAssetSavedObjects(); + }, [savedObjectsClient, packageInfo]); + + // if they arrive at this page and the package is not installed, send them to overview + // this happens if they arrive with a direct url or they uninstall while on this tab + if (packageInstallStatus.status !== InstallStatus.installed) { + return ( + + ); + } + + let content: JSX.Element | Array; + + if (isLoading) { + content = ; + } else if (fetchError) { + content = ( + + } + error={fetchError} + /> + ); + } else if (assetSavedObjects === undefined) { + content = ( + +

+ +

+
+ ); + } else { + content = allowedAssetTypes.map((assetType) => { + const sectionAssetSavedObjects = assetSavedObjects.filter((so) => so.type === assetType); + + if (!sectionAssetSavedObjects.length) { + return null; + } + + return ( + <> + + + + ); + }); + } + + return ( + + + {content} + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets_accordion.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets_accordion.tsx new file mode 100644 index 0000000000000..abfdd88d27162 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets_accordion.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { FunctionComponent } from 'react'; + +import { + EuiAccordion, + EuiFlexGroup, + EuiFlexItem, + EuiSplitPanel, + EuiSpacer, + EuiText, + EuiLink, + EuiHorizontalRule, + EuiNotificationBadge, +} from '@elastic/eui'; + +import { AssetTitleMap } from '../../../../../constants'; + +import { getHrefToObjectInKibanaApp, useStartServices } from '../../../../../hooks'; + +import type { AllowedAssetType, AssetSavedObject } from './types'; + +interface Props { + type: AllowedAssetType; + savedObjects: AssetSavedObject[]; +} + +export const AssetsAccordion: FunctionComponent = ({ savedObjects, type }) => { + const { http } = useStartServices(); + return ( + + + +

{AssetTitleMap[type]}

+
+
+ + +

{savedObjects.length}

+
+
+
+ } + id={type} + > + <> + + + {savedObjects.map(({ id, attributes: { title, description } }, idx) => { + const pathToObjectInApp = getHrefToObjectInKibanaApp({ + http, + id, + type, + }); + return ( + <> + + +

+ {pathToObjectInApp ? ( + {title} + ) : ( + title + )} +

+
+ {description && ( + <> + + +

{description}

+
+ + )} +
+ {idx + 1 < savedObjects.length && } + + ); + })} +
+ + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/constants.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/constants.ts new file mode 100644 index 0000000000000..d6d88f7935eb4 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/constants.ts @@ -0,0 +1,16 @@ +/* + * 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 { KibanaAssetType } from '../../../../../types'; + +import type { AllowedAssetTypes } from './types'; + +export const allowedAssetTypes: AllowedAssetTypes = [ + KibanaAssetType.dashboard, + KibanaAssetType.search, + KibanaAssetType.visualization, +]; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/index.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/index.ts new file mode 100644 index 0000000000000..ceb030b7ce02e --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export { AssetsPage } from './assets'; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/types.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/types.ts new file mode 100644 index 0000000000000..21efd1cd562e8 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SimpleSavedObject } from 'src/core/public'; + +import type { KibanaAssetType } from '../../../../../types'; + +export type AssetSavedObject = SimpleSavedObject<{ title: string; description?: string }>; + +export type AllowedAssetTypes = [ + KibanaAssetType.dashboard, + KibanaAssetType.search, + KibanaAssetType.visualization +]; + +export type AllowedAssetType = AllowedAssetTypes[number]; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index 99a29a8194f9b..cf6007026afeb 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -56,6 +56,7 @@ import { WithHeaderLayout } from '../../../../layouts'; import { RELEASE_BADGE_DESCRIPTION, RELEASE_BADGE_LABEL } from '../../components/release_badge'; import { IntegrationAgentPolicyCount, UpdateIcon, IconPanel, LoadingIconPanel } from './components'; +import { AssetsPage } from './assets'; import { OverviewPage } from './overview'; import { PackagePoliciesPage } from './policies'; import { SettingsPage } from './settings'; @@ -408,6 +409,24 @@ export function Detail() { }); } + if (packageInstallStatus === InstallStatus.installed && packageInfo.assets) { + tabs.push({ + id: 'assets', + name: ( + + ), + isSelected: panel === 'assets', + 'data-test-subj': `tab-assets`, + href: getHref('integration_details_assets', { + pkgkey: packageInfoKey, + ...(integration ? { integration } : {}), + }), + }); + } + tabs.push({ id: 'settings', name: ( @@ -476,6 +495,9 @@ export function Detail() { + + + diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx index 7da7328fdebbc..c672abeb1c903 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx @@ -4,9 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { stringify, parse } from 'query-string'; import React, { memo, useCallback, useMemo, useState } from 'react'; -import { Redirect } from 'react-router-dom'; +import { Redirect, useLocation, useHistory } from 'react-router-dom'; import type { CriteriaWithPagination, EuiTableFieldDataColumnType } from '@elastic/eui'; import { EuiButtonIcon, @@ -15,6 +15,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiToolTip, + EuiText, + EuiButton, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedRelative, FormattedMessage } from '@kbn/i18n/react'; @@ -66,8 +69,16 @@ interface PackagePoliciesPanelProps { version: string; } export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps) => { - const [flyoutOpenForPolicyId, setFlyoutOpenForPolicyId] = useState(null); - const { getPath } = useLink(); + const { search } = useLocation(); + const history = useHistory(); + const queryParams = useMemo(() => new URLSearchParams(search), [search]); + const agentPolicyIdFromParams = useMemo(() => queryParams.get('addAgentToPolicyId'), [ + queryParams, + ]); + const [flyoutOpenForPolicyId, setFlyoutOpenForPolicyId] = useState( + agentPolicyIdFromParams + ); + const { getPath, getHref } = useLink(); const getPackageInstallStatus = useGetPackageInstallStatus(); const packageInstallStatus = getPackageInstallStatus(name); const { pagination, pageSizeOptions, setPagination } = useUrlPagination(); @@ -87,6 +98,36 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps [setPagination] ); + const renderViewDataStepContent = useCallback( + () => ( + <> + + + {i18n.translate( + 'xpack.fleet.epm.agentEnrollment.viewDataDescription.pleaseNoteLabel', + { defaultMessage: 'Please note' } + )} + + ), + }} + /> + + + + {i18n.translate('xpack.fleet.epm.agentEnrollment.viewDataAssetsLabel', { + defaultMessage: 'View assets', + })} + + + ), + [name, version, getHref] + ); + const columns: Array> = useMemo( () => [ { @@ -186,12 +227,16 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps align: 'right', render({ agentPolicy, packagePolicy }) { return ( - + ); }, }, ], - [] + [renderViewDataStepContent] ); const noItemsMessage = useMemo(() => { @@ -236,14 +281,18 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps /> - {flyoutOpenForPolicyId && ( + {flyoutOpenForPolicyId && !isLoading && ( setFlyoutOpenForPolicyId(null)} - agentPolicies={ - data?.items - .filter(({ agentPolicy }) => agentPolicy.id === flyoutOpenForPolicyId) - .map(({ agentPolicy }) => agentPolicy) ?? [] + onClose={() => { + setFlyoutOpenForPolicyId(null); + const { addAgentToPolicyId, ...rest } = parse(search); + history.replace({ search: stringify(rest) }); + }} + agentPolicy={ + data?.items.find(({ agentPolicy }) => agentPolicy.id === flyoutOpenForPolicyId) + ?.agentPolicy } + viewDataStepContent={renderViewDataStepContent()} /> )} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/header.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/header.tsx deleted file mode 100644 index 55d058a2d7900..0000000000000 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/header.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo } from 'react'; -import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; -import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiText } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { useLinks, useStartServices } from '../../../../hooks'; - -export const HeroCopy = memo(() => { - return ( - - - -

- -

-
-
- - -

- -

-
-
-
- ); -}); - -const Illustration = styled(EuiImage)` - margin-bottom: -68px; - width: 80%; -`; - -export const HeroImage = memo(() => { - const { toAssets } = useLinks(); - const { uiSettings } = useStartServices(); - const IS_DARK_THEME = uiSettings.get('theme:darkMode'); - - return ( - - ); -}); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts index f1055e7e2583e..fcf1078566498 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts @@ -37,6 +37,7 @@ jest.mock('./steps', () => { ...module, AgentPolicySelectionStep: jest.fn(), AgentEnrollmentKeySelectionStep: jest.fn(), + ViewDataStep: jest.fn(), }; }); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx index db9245b11b0f9..65118044e98c5 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx @@ -21,7 +21,7 @@ import { FleetStatusProvider, ConfigContext } from '../../hooks'; import { useFleetServerInstructions } from '../../applications/fleet/sections/agents/agent_requirements_page'; -import { AgentEnrollmentKeySelectionStep, AgentPolicySelectionStep } from './steps'; +import { AgentEnrollmentKeySelectionStep, AgentPolicySelectionStep, ViewDataStep } from './steps'; import type { Props } from '.'; import { AgentEnrollmentFlyout } from '.'; @@ -128,6 +128,46 @@ describe('', () => { expect(AgentEnrollmentKeySelectionStep).toHaveBeenCalled(); }); }); + + describe('"View data" extension point', () => { + it('calls the "View data" step when UI extension is provided', async () => { + jest.clearAllMocks(); + await act(async () => { + testBed = await setup({ + agentPolicies: [], + onClose: jest.fn(), + viewDataStepContent:
, + }); + testBed.component.update(); + }); + const { exists, actions } = testBed; + expect(exists('agentEnrollmentFlyout')).toBe(true); + expect(ViewDataStep).toHaveBeenCalled(); + + jest.clearAllMocks(); + actions.goToStandaloneTab(); + expect(ViewDataStep).not.toHaveBeenCalled(); + }); + + it('does not call the "View data" step when UI extension is not provided', async () => { + jest.clearAllMocks(); + await act(async () => { + testBed = await setup({ + agentPolicies: [], + onClose: jest.fn(), + viewDataStepContent: undefined, + }); + testBed.component.update(); + }); + const { exists, actions } = testBed; + expect(exists('agentEnrollmentFlyout')).toBe(true); + expect(ViewDataStep).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + actions.goToStandaloneTab(); + expect(ViewDataStep).not.toHaveBeenCalled(); + }); + }); }); describe('standalone instructions', () => { diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx index b91af80691033..58362d85e2fb3 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx @@ -42,6 +42,7 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ onClose, agentPolicy, agentPolicies, + viewDataStepContent, }) => { const [mode, setMode] = useState<'managed' | 'standalone'>('managed'); @@ -109,9 +110,17 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ } > {fleetServerHosts.length === 0 && mode === 'managed' ? null : mode === 'managed' ? ( - + ) : ( - + )} diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx index e7045173f1257..919f0c3052db9 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx @@ -21,7 +21,12 @@ import { useFleetServerInstructions, } from '../../applications/fleet/sections/agents/agent_requirements_page'; -import { DownloadStep, AgentPolicySelectionStep, AgentEnrollmentKeySelectionStep } from './steps'; +import { + DownloadStep, + AgentPolicySelectionStep, + AgentEnrollmentKeySelectionStep, + ViewDataStep, +} from './steps'; import type { BaseProps } from './types'; type Props = BaseProps; @@ -53,83 +58,91 @@ const FleetServerMissingRequirements = () => { return ; }; -export const ManagedInstructions = React.memo(({ agentPolicy, agentPolicies }) => { - const fleetStatus = useFleetStatus(); +export const ManagedInstructions = React.memo( + ({ agentPolicy, agentPolicies, viewDataStepContent }) => { + const fleetStatus = useFleetStatus(); - const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); - const [isFleetServerPolicySelected, setIsFleetServerPolicySelected] = useState(false); + const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); + const [isFleetServerPolicySelected, setIsFleetServerPolicySelected] = useState(false); - const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); - const settings = useGetSettings(); - const fleetServerInstructions = useFleetServerInstructions(apiKey?.data?.item?.policy_id); + const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); + const settings = useGetSettings(); + const fleetServerInstructions = useFleetServerInstructions(apiKey?.data?.item?.policy_id); - const steps = useMemo(() => { - const { - serviceToken, - getServiceToken, - isLoadingServiceToken, - installCommand, - platform, - setPlatform, - } = fleetServerInstructions; - const fleetServerHosts = settings.data?.item?.fleet_server_hosts || []; - const baseSteps: EuiContainedStepProps[] = [ - DownloadStep(), - !agentPolicy - ? AgentPolicySelectionStep({ - agentPolicies, - setSelectedAPIKeyId, - setIsFleetServerPolicySelected, - }) - : AgentEnrollmentKeySelectionStep({ agentPolicy, setSelectedAPIKeyId }), - ]; - if (isFleetServerPolicySelected) { - baseSteps.push( - ...[ - ServiceTokenStep({ serviceToken, getServiceToken, isLoadingServiceToken }), - FleetServerCommandStep({ serviceToken, installCommand, platform, setPlatform }), - ] - ); - } else { - baseSteps.push({ - title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', { - defaultMessage: 'Enroll and start the Elastic Agent', - }), - children: selectedAPIKeyId && apiKey.data && ( - - ), - }); - } - return baseSteps; - }, [ - agentPolicy, - agentPolicies, - selectedAPIKeyId, - apiKey.data, - isFleetServerPolicySelected, - settings.data?.item?.fleet_server_hosts, - fleetServerInstructions, - ]); + const steps = useMemo(() => { + const { + serviceToken, + getServiceToken, + isLoadingServiceToken, + installCommand, + platform, + setPlatform, + } = fleetServerInstructions; + const fleetServerHosts = settings.data?.item?.fleet_server_hosts || []; + const baseSteps: EuiContainedStepProps[] = [ + DownloadStep(), + !agentPolicy + ? AgentPolicySelectionStep({ + agentPolicies, + setSelectedAPIKeyId, + setIsFleetServerPolicySelected, + }) + : AgentEnrollmentKeySelectionStep({ agentPolicy, setSelectedAPIKeyId }), + ]; + if (isFleetServerPolicySelected) { + baseSteps.push( + ...[ + ServiceTokenStep({ serviceToken, getServiceToken, isLoadingServiceToken }), + FleetServerCommandStep({ serviceToken, installCommand, platform, setPlatform }), + ] + ); + } else { + baseSteps.push({ + title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', { + defaultMessage: 'Enroll and start the Elastic Agent', + }), + children: selectedAPIKeyId && apiKey.data && ( + + ), + }); + } - return ( - <> - {fleetStatus.isReady ? ( - <> - - - - - - - ) : fleetStatus.missingRequirements?.length === 1 && - fleetStatus.missingRequirements[0] === 'fleet_server' ? ( - - ) : ( - - )} - - ); -}); + if (viewDataStepContent) { + baseSteps.push(ViewDataStep(viewDataStepContent)); + } + + return baseSteps; + }, [ + agentPolicy, + agentPolicies, + selectedAPIKeyId, + apiKey.data, + isFleetServerPolicySelected, + settings.data?.item?.fleet_server_hosts, + fleetServerInstructions, + viewDataStepContent, + ]); + + return ( + <> + {fleetStatus.isReady ? ( + <> + + + + + + + ) : fleetStatus.missingRequirements?.length === 1 && + fleetStatus.missingRequirements[0] === 'fleet_server' ? ( + + ) : ( + + )} + + ); + } +); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/missing_fleet_server_host_callout.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/missing_fleet_server_host_callout.tsx index 636032552a1ae..169ff86b40c88 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/missing_fleet_server_host_callout.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/missing_fleet_server_host_callout.tsx @@ -10,10 +10,12 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCallOut, EuiLink, EuiButton, EuiSpacer } from '@elastic/eui'; -import { useUrlModal } from '../../hooks'; +import { useUrlModal, useStartServices } from '../../hooks'; export const MissingFleetServerHostCallout: React.FunctionComponent = () => { const { setModal } = useUrlModal(); + const { docLinks } = useStartServices(); + return ( { defaultMessage="A URL for your Fleet Server host is required to enroll agents with Fleet. You can add this information in Fleet Settings. For more information, see the {link}." values={{ link: ( - + { + return { + title: i18n.translate('xpack.fleet.agentEnrollment.stepViewDataTitle', { + defaultMessage: 'View your data', + }), + children: content, + }; +}; diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts index b9bcf8fb3e4b2..e0c5b040a61fb 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts @@ -9,12 +9,20 @@ import type { AgentPolicy } from '../../types'; export interface BaseProps { /** - * The user selected policy to be used + * The user selected policy to be used. If this value is `undefined` a value must be provided for `agentPolicies`. */ agentPolicy?: AgentPolicy; /** - * A selection of policies for the user to choose from, will be ignored if `agentPolicy` has been provided + * A selection of policies for the user to choose from, will be ignored if `agentPolicy` has been provided. + * + * If this value is `undefined` a value must be provided for `agentPolicy`. */ agentPolicies?: AgentPolicy[]; + + /** + * There is a step in the agent enrollment process that allows users to see the data from an integration represented in the UI + * in some way. This is an area for consumers to render a button and text explaining how data can be viewed. + */ + viewDataStepContent?: JSX.Element; } diff --git a/x-pack/plugins/fleet/public/components/alpha_flyout.tsx b/x-pack/plugins/fleet/public/components/alpha_flyout.tsx index c91d80124dd35..b8606968c22dd 100644 --- a/x-pack/plugins/fleet/public/components/alpha_flyout.tsx +++ b/x-pack/plugins/fleet/public/components/alpha_flyout.tsx @@ -18,11 +18,15 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { useStartServices } from '../hooks'; + interface Props { onClose: () => void; } export const AlphaFlyout: React.FunctionComponent = ({ onClose }) => { + const { docLinks } = useStartServices(); + return ( @@ -49,11 +53,7 @@ export const AlphaFlyout: React.FunctionComponent = ({ onClose }) => { defaultMessage="Read our {docsLink} or go to our {forumLink} for questions or feedback." values={{ docsLink: ( - + = ({ fleetServerHosts, }) => { const { platform, setPlatform } = usePlatform(); + const { docLinks } = useStartServices(); const enrollArgs = getfleetServerHostsEnrollArgs(apiKey, fleetServerHosts); @@ -85,11 +86,7 @@ export const ManualInstructions: React.FunctionComponent = ({ defaultMessage="See the {link} for RPM / DEB deploy instructions." values={{ link: ( - + = ({ defaultMessage="If you are having trouble connecting, see our {link}." values={{ link: ( - + = ({ agentPolicy, packagePolicy }) => { + viewDataStepContent?: JSX.Element; +}> = ({ agentPolicy, packagePolicy, viewDataStepContent }) => { const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); const { getHref } = useLink(); const hasWriteCapabilities = useCapabilities().write; @@ -103,7 +104,11 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{ <> {isEnrollmentFlyoutOpen && ( - + )} diff --git a/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx index 6e09310823b32..d748e655bd506 100644 --- a/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx @@ -189,6 +189,8 @@ function useSettingsForm(outputId: string | undefined, onSuccess: () => void) { } export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { + const { docLinks } = useStartServices(); + const settingsRequest = useGetSettings(); const settings = settingsRequest?.data?.item; const outputsRequest = useGetOutputs(); @@ -302,7 +304,7 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { values={{ link: ( diff --git a/x-pack/plugins/fleet/public/constants/page_paths.ts b/x-pack/plugins/fleet/public/constants/page_paths.ts index 326cfd804bd57..1688a396cd5a1 100644 --- a/x-pack/plugins/fleet/public/constants/page_paths.ts +++ b/x-pack/plugins/fleet/public/constants/page_paths.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { stringify } from 'query-string'; + export type StaticPage = | 'base' | 'overview' @@ -19,6 +21,7 @@ export type StaticPage = export type DynamicPage = | 'integration_details_overview' | 'integration_details_policies' + | 'integration_details_assets' | 'integration_details_settings' | 'integration_details_custom' | 'integration_policy_edit' @@ -66,6 +69,7 @@ export const INTEGRATIONS_ROUTING_PATHS = { integration_details: '/detail/:pkgkey/:panel?', integration_details_overview: '/detail/:pkgkey/overview', integration_details_policies: '/detail/:pkgkey/policies', + integration_details_assets: '/detail/:pkgkey/assets', integration_details_settings: '/detail/:pkgkey/settings', integration_details_custom: '/detail/:pkgkey/custom', integration_policy_edit: '/edit-integration/:packagePolicyId', @@ -86,9 +90,13 @@ export const pagePathGetters: { INTEGRATIONS_BASE_PATH, `/detail/${pkgkey}/overview${integration ? `?integration=${integration}` : ''}`, ], - integration_details_policies: ({ pkgkey, integration }) => [ + integration_details_policies: ({ pkgkey, integration, addAgentToPolicyId }) => { + const qs = stringify({ integration, addAgentToPolicyId }); + return [INTEGRATIONS_BASE_PATH, `/detail/${pkgkey}/policies${qs ? `?${qs}` : ''}`]; + }, + integration_details_assets: ({ pkgkey, integration }) => [ INTEGRATIONS_BASE_PATH, - `/detail/${pkgkey}/policies${integration ? `?integration=${integration}` : ''}`, + `/detail/${pkgkey}/assets${integration ? `?integration=${integration}` : ''}`, ], integration_details_settings: ({ pkgkey, integration }) => [ INTEGRATIONS_BASE_PATH, @@ -108,6 +116,7 @@ export const pagePathGetters: { FLEET_BASE_PATH, `/policies/${policyId}${tabId ? `/${tabId}` : ''}`, ], + // TODO: This might need to be removed because we do not have a way to pick an integration in line anymore add_integration_from_policy: ({ policyId }) => [ FLEET_BASE_PATH, `/policies/${policyId}/add-integration`, diff --git a/x-pack/plugins/fleet/public/hooks/index.ts b/x-pack/plugins/fleet/public/hooks/index.ts index 9f41e5c7cc92b..a00c0c5dacf11 100644 --- a/x-pack/plugins/fleet/public/hooks/index.ts +++ b/x-pack/plugins/fleet/public/hooks/index.ts @@ -11,7 +11,7 @@ export { useConfig, ConfigContext } from './use_config'; export { useKibanaVersion, KibanaVersionContext } from './use_kibana_version'; export { licenseService, useLicense } from './use_license'; export { useLink } from './use_link'; -export { useKibanaLink } from './use_kibana_link'; +export { useKibanaLink, getHrefToObjectInKibanaApp } from './use_kibana_link'; export { usePackageIconType, UsePackageIconType } from './use_package_icon_type'; export { usePagination, Pagination, PAGE_SIZE_OPTIONS } from './use_pagination'; export { useUrlPagination } from './use_url_pagination'; diff --git a/x-pack/plugins/fleet/public/hooks/use_core.ts b/x-pack/plugins/fleet/public/hooks/use_core.ts index be4a21a094bd4..2c817bfc938f8 100644 --- a/x-pack/plugins/fleet/public/hooks/use_core.ts +++ b/x-pack/plugins/fleet/public/hooks/use_core.ts @@ -13,5 +13,6 @@ export function useStartServices(): FleetStartServices { if (services === null) { throw new Error('KibanaContextProvider not initialized'); } + return services; } diff --git a/x-pack/plugins/fleet/public/hooks/use_kibana_link.ts b/x-pack/plugins/fleet/public/hooks/use_kibana_link.ts index 29f4f8748d1a0..3ad01620b9780 100644 --- a/x-pack/plugins/fleet/public/hooks/use_kibana_link.ts +++ b/x-pack/plugins/fleet/public/hooks/use_kibana_link.ts @@ -4,12 +4,62 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { HttpStart } from 'src/core/public'; + +import { KibanaAssetType } from '../types'; import { useStartServices } from './'; const KIBANA_BASE_PATH = '/app/kibana'; +const getKibanaLink = (http: HttpStart, path: string) => { + return http.basePath.prepend(`${KIBANA_BASE_PATH}#${path}`); +}; + +/** + * TODO: This is a temporary solution for getting links to various assets. It is very risky because: + * + * 1. The plugin might not exist/be enabled + * 2. URLs and paths might not always be supported + * + * We should migrate to using the new URL service locators. + * + * @deprecated {@link Locators} from the new URL service need to be used instead. + + */ +export const getHrefToObjectInKibanaApp = ({ + type, + id, + http, +}: { + type: KibanaAssetType; + id: string; + http: HttpStart; +}): undefined | string => { + let kibanaAppPath: undefined | string; + switch (type) { + case KibanaAssetType.dashboard: + kibanaAppPath = `/dashboard/${id}`; + break; + case KibanaAssetType.search: + kibanaAppPath = `/discover/${id}`; + break; + case KibanaAssetType.visualization: + kibanaAppPath = `/visualize/edit/${id}`; + break; + default: + return undefined; + } + + return getKibanaLink(http, kibanaAppPath); +}; + +/** + * TODO: This functionality needs to be replaced with use of the new URL service locators + * + * @deprecated {@link Locators} from the new URL service need to be used instead. + */ export function useKibanaLink(path: string = '/') { - const core = useStartServices(); - return core.http.basePath.prepend(`${KIBANA_BASE_PATH}#${path}`); + const { http } = useStartServices(); + return getKibanaLink(http, path); } diff --git a/x-pack/plugins/fleet/public/search_provider.test.ts b/x-pack/plugins/fleet/public/search_provider.test.ts index fa2e8508c938e..c42ac2e5a1551 100644 --- a/x-pack/plugins/fleet/public/search_provider.test.ts +++ b/x-pack/plugins/fleet/public/search_provider.test.ts @@ -185,5 +185,105 @@ describe('Package search provider', () => { expect(sendGetPackages).toHaveBeenCalledTimes(1); }); + + describe('tags', () => { + test('without packages tag, without search term', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + mockSendGetPackages.mockReturnValue( + hot('--(a|)', { a: { data: { response: testResponse } } }) + ); + setupMock.getStartServices.mockReturnValue( + hot('--(a|)', { a: [coreMock.createStart()] }) as any + ); + const packageSearchProvider = createPackageSearchProvider(setupMock); + expectObservable( + packageSearchProvider.find( + { types: ['test'] }, + { aborted$: NEVER, maxResults: 100, preference: '' } + ) + ).toBe('(a|)', { + a: [], + }); + }); + + expect(sendGetPackages).toHaveBeenCalledTimes(0); + }); + + test('with packages tag, with no search term', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + mockSendGetPackages.mockReturnValue( + hot('--(a|)', { a: { data: { response: testResponse } } }) + ); + setupMock.getStartServices.mockReturnValue( + hot('--(a|)', { a: [coreMock.createStart()] }) as any + ); + const packageSearchProvider = createPackageSearchProvider(setupMock); + expectObservable( + packageSearchProvider.find( + { types: ['package'] }, + { aborted$: NEVER, maxResults: 100, preference: '' } + ) + ).toBe('--(a|)', { + a: [ + { + id: 'test-test', + score: 80, + title: 'test', + type: 'package', + url: { + path: 'undefined#/detail/test-test/overview', + prependBasePath: false, + }, + }, + { + id: 'test1-test1', + score: 80, + title: 'test1', + type: 'package', + url: { + path: 'undefined#/detail/test1-test1/overview', + prependBasePath: false, + }, + }, + ], + }); + }); + + expect(sendGetPackages).toHaveBeenCalledTimes(1); + }); + + test('with packages tag, with search term', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + mockSendGetPackages.mockReturnValue( + hot('--(a|)', { a: { data: { response: testResponse } } }) + ); + setupMock.getStartServices.mockReturnValue( + hot('--(a|)', { a: [coreMock.createStart()] }) as any + ); + const packageSearchProvider = createPackageSearchProvider(setupMock); + expectObservable( + packageSearchProvider.find( + { term: 'test1', types: ['package'] }, + { aborted$: NEVER, maxResults: 100, preference: '' } + ) + ).toBe('--(a|)', { + a: [ + { + id: 'test1-test1', + score: 80, + title: 'test1', + type: 'package', + url: { + path: 'undefined#/detail/test1-test1/overview', + prependBasePath: false, + }, + }, + ], + }); + }); + + expect(sendGetPackages).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/x-pack/plugins/fleet/public/search_provider.ts b/x-pack/plugins/fleet/public/search_provider.ts index cd4ec1c29b457..56e08ecad29fb 100644 --- a/x-pack/plugins/fleet/public/search_provider.ts +++ b/x-pack/plugins/fleet/public/search_provider.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { CoreSetup, CoreStart } from 'src/core/public'; +import type { CoreSetup, CoreStart, ApplicationStart } from 'src/core/public'; import type { Observable } from 'rxjs'; import { from, of, combineLatest } from 'rxjs'; @@ -34,6 +34,26 @@ const createPackages$ = () => shareReplay(1) ); +const toSearchResult = ( + pkg: GetPackagesResponse['response'][number], + application: ApplicationStart +) => { + const pkgkey = `${pkg.name}-${pkg.version}`; + return { + id: pkgkey, + type: packageType, + title: pkg.title, + score: 80, + url: { + // TODO: See https://github.com/elastic/kibana/issues/96134 for details about why we use '#' here. Below should be updated + // as part of migrating to non-hash based router. + // prettier-ignore + path: `${application.getUrlForApp(INTEGRATIONS_PLUGIN_ID)}#${pagePathGetters.integration_details_overview({ pkgkey })[1]}`, + prependBasePath: false, + }, + }; +}; + export const createPackageSearchProvider = (core: CoreSetup): GlobalSearchResultProvider => { const coreStart$ = from(core.getStartServices()).pipe( map(([coreStart]) => coreStart), @@ -52,12 +72,23 @@ export const createPackageSearchProvider = (core: CoreSetup): GlobalSearchResult return { id: 'packages', getSearchableTypes: () => [packageType], - find: ({ term }, { maxResults, aborted$ }) => { - if (!term) { + find: ({ term, types }, { maxResults, aborted$ }) => { + if (types?.includes(packageType) === false) { return of([]); } - term = term.toLowerCase(); + const hasTypes = Boolean(types); + const typesIncludePackage = hasTypes && types!.includes(packageType); + const noSearchTerm = !term; + const includeAllPackages = typesIncludePackage && noSearchTerm; + + if (!includeAllPackages && noSearchTerm) { + return of([]); + } + + if (term) { + term = term.toLowerCase(); + } const toSearchResults = ( coreStart: CoreStart, @@ -65,25 +96,17 @@ export const createPackageSearchProvider = (core: CoreSetup): GlobalSearchResult ): GlobalSearchProviderResult[] => { const packages = packagesResponse.slice(0, maxResults); - return packages.flatMap((pkg) => { - if (!term || !pkg.title.toLowerCase().includes(term)) { - return []; - } - const pkgkey = `${pkg.name}-${pkg.version}`; - return { - id: pkgkey, - type: packageType, - title: pkg.title, - score: 80, - url: { - // TODO: See https://github.com/elastic/kibana/issues/96134 for details about why we use '#' here. Below should be updated - // as part of migrating to non-hash based router. - // prettier-ignore - path: `${coreStart.application.getUrlForApp(INTEGRATIONS_PLUGIN_ID)}#${pagePathGetters.integration_details_overview({ pkgkey })[1]}`, - prependBasePath: false, - }, - }; - }); + return packages.flatMap( + includeAllPackages + ? (pkg) => toSearchResult(pkg, coreStart.application) + : (pkg) => { + if (!term || !pkg.title.toLowerCase().includes(term)) { + return []; + } + + return toSearchResult(pkg, coreStart.application); + } + ); }; return combineLatest([coreStart$, getPackages$()]).pipe( diff --git a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts index b80ddfe8e7c9b..073ff7806d9fe 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { estypes } from '@elastic/elasticsearch'; import { keyBy, keys, merge } from 'lodash'; import type { RequestHandler, SavedObjectsBulkGetObject } from 'src/core/server'; @@ -140,10 +140,7 @@ export const getListHandler: RequestHandler = async (context, request, response) // Query backing indices to extract data stream dataset, namespace, and type values const { - body: { - // @ts-expect-error @elastic/elasticsearch aggregations are not typed - aggregations: { dataset, namespace, type }, - }, + body: { aggregations: dataStreamAggs }, } = await esClient.search({ index: dataStream.indices.map((index) => index.index_name), body: { @@ -187,6 +184,11 @@ export const getListHandler: RequestHandler = async (context, request, response) }, }); + const { dataset, namespace, type } = dataStreamAggs as Record< + string, + estypes.AggregationsMultiBucketAggregate<{ key?: string }> + >; + // Set values from backing indices query dataStreamResponse.dataset = dataset.buckets[0]?.key || ''; dataStreamResponse.namespace = namespace.buckets[0]?.key || ''; diff --git a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts index 8f575f4969cf4..14d43e6e219db 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts @@ -47,7 +47,7 @@ export async function listEnrollmentApiKeys( body: query ? { query } : undefined, }); - // @ts-expect-error @elastic/elasticsearch + // @ts-expect-error @elastic/elasticsearch _source is optional const items = res.body.hits.hits.map(esDocToEnrollmentApiKey); return { diff --git a/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts b/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts index 5681be3e8793b..b046b41d73722 100644 --- a/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts +++ b/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts @@ -125,6 +125,7 @@ describe('When using the artifacts services', () => { expect(esClientMock.delete).toHaveBeenCalledWith({ index: FLEET_SERVER_ARTIFACTS_INDEX, id: '123', + refresh: 'wait_for', }); }); diff --git a/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts b/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts index 26032ab94dbc8..6ac23cb1f9ef8 100644 --- a/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts +++ b/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts @@ -87,6 +87,7 @@ export const deleteArtifact = async (esClient: ElasticsearchClient, id: string): await esClient.delete({ index: FLEET_SERVER_ARTIFACTS_INDEX, id, + refresh: 'wait_for', }); } catch (e) { throw new ArtifactsElasticsearchError(e); diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts index bc4ffffb68358..1be0f73a347e9 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts @@ -129,6 +129,13 @@ processors: password: {{password}} {{#if password}} hidden_password: {{password}} +{{/if}} + `; + const streamTemplateWithString = ` +{{#if (contains ".pcap" file)}} +pcap: true +{{else}} +pcap: false {{/if}} `; @@ -168,6 +175,28 @@ hidden_password: {{password}} tags: ['foo', 'bar'], }); }); + + it('should support strings', () => { + const vars = { + file: { value: 'foo.pcap' }, + }; + + const output = compileTemplate(vars, streamTemplateWithString); + expect(output).toEqual({ + pcap: true, + }); + }); + + it('should support strings with no match', () => { + const vars = { + file: { value: 'file' }, + }; + + const output = compileTemplate(vars, streamTemplateWithString); + expect(output).toEqual({ + pcap: false, + }); + }); }); it('should support optional yaml values at root level', () => { diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts index 84a8ab581354a..a0d14e6962a8d 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts @@ -111,11 +111,12 @@ function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateSt return { vars, yamlValues }; } -function containsHelper(this: any, item: string, list: string[], options: any) { - if (Array.isArray(list) && list.includes(item)) { +function containsHelper(this: any, item: string, check: string | string[], options: any) { + if ((Array.isArray(check) || typeof check === 'string') && check.includes(item)) { if (options && options.fn) { return options.fn(this); } + return true; } return ''; } diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts index 4c0484c058abf..f929a4f139981 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts @@ -59,25 +59,26 @@ processors: } String verified(def ctx, def params) { - // Agents only use API keys. - if (ctx?._security?.authentication_type == null || ctx._security.authentication_type != 'API_KEY') { - return "no_api_key"; + // No agent.id field to validate. + if (ctx?.agent?.id == null) { + return "missing"; } - // Verify the API key owner before trusting any metadata it contains. - if (!is_user_trusted(ctx, params.trusted_users)) { - return "untrusted_user"; - } - - // API keys created by Fleet include metadata about the agent they were issued to. - if (ctx?._security?.api_key?.metadata?.agent_id == null || ctx?.agent?.id == null) { - return "missing_metadata"; + // Check auth metadata from API key. + if (ctx?._security?.authentication_type == null + // Agents only use API keys. + || ctx._security.authentication_type != 'API_KEY' + // Verify the API key owner before trusting any metadata it contains. + || !is_user_trusted(ctx, params.trusted_users) + // Verify the API key has metadata indicating the assigned agent ID. + || ctx?._security?.api_key?.metadata?.agent_id == null) { + return "auth_metadata_missing"; } // The API key can only be used represent the agent.id it was issued to. if (ctx._security.api_key.metadata.agent_id != ctx.agent.id) { // Potential masquerade attempt. - return "agent_id_mismatch"; + return "mismatch"; } return "verified"; diff --git a/x-pack/plugins/index_lifecycle_management/public/index.ts b/x-pack/plugins/index_lifecycle_management/public/index.ts index 9bfff971d5e71..cbd23a14a6114 100644 --- a/x-pack/plugins/index_lifecycle_management/public/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/index.ts @@ -14,4 +14,4 @@ export const plugin = (initializerContext: PluginInitializerContext) => { return new IndexLifecycleManagementPlugin(initializerContext); }; -export { ILM_URL_GENERATOR_ID, IlmUrlGeneratorState } from './url_generator'; +export { ILM_LOCATOR_ID, IlmLocatorParams } from './locator'; diff --git a/x-pack/plugins/index_lifecycle_management/public/locator.ts b/x-pack/plugins/index_lifecycle_management/public/locator.ts new file mode 100644 index 0000000000000..025946a095a6f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/locator.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SerializableState } from 'src/plugins/kibana_utils/common'; +import { ManagementAppLocator } from 'src/plugins/management/common'; +import { LocatorDefinition } from '../../../../src/plugins/share/public/'; +import { + getPoliciesListPath, + getPolicyCreatePath, + getPolicyEditPath, +} from './application/services/navigation'; +import { PLUGIN } from '../common/constants'; + +export const ILM_LOCATOR_ID = 'ILM_LOCATOR_ID'; + +export interface IlmLocatorParams extends SerializableState { + page: 'policies_list' | 'policy_edit' | 'policy_create'; + policyName?: string; +} + +export interface IlmLocatorDefinitionDependencies { + managementAppLocator: ManagementAppLocator; +} + +export class IlmLocatorDefinition implements LocatorDefinition { + constructor(protected readonly deps: IlmLocatorDefinitionDependencies) {} + + public readonly id = ILM_LOCATOR_ID; + + public readonly getLocation = async (params: IlmLocatorParams) => { + const location = await this.deps.managementAppLocator.getLocation({ + sectionId: 'data', + appId: PLUGIN.ID, + }); + + switch (params.page) { + case 'policy_create': { + return { + ...location, + path: location.path + getPolicyCreatePath(), + }; + } + case 'policy_edit': { + return { + ...location, + path: location.path + getPolicyEditPath(params.policyName!), + }; + } + case 'policies_list': { + return { + ...location, + path: location.path + getPoliciesListPath(), + }; + } + } + }; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx index 069d1e0d10e0b..163fe2b3d9b5c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx @@ -17,7 +17,7 @@ import { init as initNotification } from './application/services/notification'; import { BreadcrumbService } from './application/services/breadcrumbs'; import { addAllExtensions } from './extend_index_management'; import { ClientConfigType, SetupDependencies, StartDependencies } from './types'; -import { registerUrlGenerator } from './url_generator'; +import { IlmLocatorDefinition } from './locator'; export class IndexLifecycleManagementPlugin implements Plugin { @@ -38,7 +38,7 @@ export class IndexLifecycleManagementPlugin getStartServices, } = coreSetup; - const { usageCollection, management, indexManagement, home, cloud, share } = plugins; + const { usageCollection, management, indexManagement, home, cloud } = plugins; // Initialize services even if the app isn't mounted, because they're used by index management extensions. initHttp(http); @@ -110,7 +110,11 @@ export class IndexLifecycleManagementPlugin addAllExtensions(indexManagement.extensionsService); } - registerUrlGenerator(coreSetup, management, share); + plugins.share.url.locators.create( + new IlmLocatorDefinition({ + managementAppLocator: plugins.management.locator, + }) + ); } } diff --git a/x-pack/plugins/index_lifecycle_management/public/url_generator.ts b/x-pack/plugins/index_lifecycle_management/public/url_generator.ts deleted file mode 100644 index f7794c535198f..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/url_generator.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CoreSetup } from 'kibana/public'; -import { UrlGeneratorsDefinition } from '../../../../src/plugins/share/public/'; -import { - getPoliciesListPath, - getPolicyCreatePath, - getPolicyEditPath, -} from './application/services/navigation'; -import { MANAGEMENT_APP_ID } from '../../../../src/plugins/management/public'; -import { SetupDependencies } from './types'; -import { PLUGIN } from '../common/constants'; - -export const ILM_URL_GENERATOR_ID = 'ILM_URL_GENERATOR_ID'; - -export interface IlmUrlGeneratorState { - page: 'policies_list' | 'policy_edit' | 'policy_create'; - policyName?: string; - absolute?: boolean; -} -export const createIlmUrlGenerator = ( - getAppBasePath: (absolute?: boolean) => Promise -): UrlGeneratorsDefinition => { - return { - id: ILM_URL_GENERATOR_ID, - createUrl: async (state: IlmUrlGeneratorState): Promise => { - switch (state.page) { - case 'policy_create': { - return `${await getAppBasePath(!!state.absolute)}${getPolicyCreatePath()}`; - } - case 'policy_edit': { - return `${await getAppBasePath(!!state.absolute)}${getPolicyEditPath(state.policyName!)}`; - } - case 'policies_list': { - return `${await getAppBasePath(!!state.absolute)}${getPoliciesListPath()}`; - } - } - }, - }; -}; - -export const registerUrlGenerator = ( - coreSetup: CoreSetup, - management: SetupDependencies['management'], - share: SetupDependencies['share'] -) => { - const getAppBasePath = async (absolute = false) => { - const [coreStart] = await coreSetup.getStartServices(); - return coreStart.application.getUrlForApp(MANAGEMENT_APP_ID, { - path: management.sections.section.data.getApp(PLUGIN.ID)!.basePath, - absolute, - }); - }; - - share.urlGenerators.registerUrlGenerator(createIlmUrlGenerator(getAppBasePath)); -}; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts index 93cd772ce6658..8e114b0596948 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -22,6 +22,21 @@ import { const nonBreakingSpace = ' '; +const urlServiceMock = { + locators: { + get: () => ({ + getLocation: async () => ({ + app: '', + path: '', + state: {}, + }), + getUrl: async ({ policyName }: { policyName: string }) => `/test/${policyName}`, + navigate: async () => {}, + useUrl: () => '', + }), + }, +}; + describe('Data Streams tab', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); let testBed: DataStreamsTabTestBed; @@ -38,7 +53,9 @@ describe('Data Streams tab', () => { }); test('displays an empty prompt', async () => { - testBed = await setup(); + testBed = await setup({ + url: urlServiceMock, + }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -54,6 +71,7 @@ describe('Data Streams tab', () => { test('when Ingest Manager is disabled, goes to index templates tab when "Get started" link is clicked', async () => { testBed = await setup({ plugins: {}, + url: urlServiceMock, }); await act(async () => { @@ -73,6 +91,7 @@ describe('Data Streams tab', () => { test('when Fleet is enabled, links to Fleet', async () => { testBed = await setup({ plugins: { isFleetEnabled: true }, + url: urlServiceMock, }); await act(async () => { @@ -95,6 +114,7 @@ describe('Data Streams tab', () => { testBed = await setup({ plugins: {}, + url: urlServiceMock, }); await act(async () => { @@ -345,6 +365,7 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), + url: urlServiceMock, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -370,13 +391,8 @@ describe('Data Streams tab', () => { }); }); - describe('url generators', () => { - const mockIlmUrlGenerator = { - getUrlGenerator: () => ({ - createUrl: ({ policyName }: { policyName: string }) => `/test/${policyName}`, - }), - }; - test('with an ILM url generator and an ILM policy', async () => { + describe('url locators', () => { + test('with an ILM url locator and an ILM policy', async () => { const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; const dataStreamForDetailPanel = createDataStreamPayload({ @@ -388,7 +404,7 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), - urlGenerators: mockIlmUrlGenerator, + url: urlServiceMock, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -400,7 +416,7 @@ describe('Data Streams tab', () => { expect(findDetailPanelIlmPolicyLink().prop('href')).toBe('/test/my_ilm_policy'); }); - test('with an ILM url generator and no ILM policy', async () => { + test('with an ILM url locator and no ILM policy', async () => { const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; const dataStreamForDetailPanel = createDataStreamPayload({ name: 'dataStream1' }); @@ -409,7 +425,7 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), - urlGenerators: mockIlmUrlGenerator, + url: urlServiceMock, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -422,7 +438,7 @@ describe('Data Streams tab', () => { expect(findDetailPanelIlmPolicyName().contains('None')).toBeTruthy(); }); - test('without an ILM url generator and with an ILM policy', async () => { + test('without an ILM url locator and with an ILM policy', async () => { const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; const dataStreamForDetailPanel = createDataStreamPayload({ @@ -434,7 +450,11 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), - urlGenerators: { getUrlGenerator: () => {} }, + url: { + locators: { + get: () => undefined, + }, + }, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -463,6 +483,7 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), + url: urlServiceMock, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -506,6 +527,7 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), + url: urlServiceMock, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -542,7 +564,7 @@ describe('Data Streams tab', () => { beforeEach(async () => { setLoadDataStreamsResponse([dataStreamWithDelete, dataStreamNoDelete]); - testBed = await setup({ history: createMemoryHistory() }); + testBed = await setup({ history: createMemoryHistory(), url: urlServiceMock }); await act(async () => { testBed.actions.goToDataStreamsList(); }); diff --git a/x-pack/plugins/index_management/public/application/app_context.tsx b/x-pack/plugins/index_management/public/application/app_context.tsx index 3b06d76cf7c26..f8ebfdf7c46b7 100644 --- a/x-pack/plugins/index_management/public/application/app_context.tsx +++ b/x-pack/plugins/index_management/public/application/app_context.tsx @@ -35,7 +35,7 @@ export interface AppDependencies { history: ScopedHistory; setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; uiSettings: CoreSetup['uiSettings']; - urlGenerators: SharePluginStart['urlGenerators']; + url: SharePluginStart['url']; docLinks: CoreStart['docLinks']; } diff --git a/x-pack/plugins/index_management/public/application/constants/ilm_url_generator.ts b/x-pack/plugins/index_management/public/application/constants/ilm_locator.ts similarity index 83% rename from x-pack/plugins/index_management/public/application/constants/ilm_url_generator.ts rename to x-pack/plugins/index_management/public/application/constants/ilm_locator.ts index ea6cf1756b73c..3da13727af8de 100644 --- a/x-pack/plugins/index_management/public/application/constants/ilm_url_generator.ts +++ b/x-pack/plugins/index_management/public/application/constants/ilm_locator.ts @@ -5,5 +5,5 @@ * 2.0. */ -export const ILM_URL_GENERATOR_ID = 'ILM_URL_GENERATOR_ID'; +export const ILM_LOCATOR_ID = 'ILM_LOCATOR_ID'; export const ILM_PAGES_POLICY_EDIT = 'policy_edit'; diff --git a/x-pack/plugins/index_management/public/application/constants/index.ts b/x-pack/plugins/index_management/public/application/constants/index.ts index 3bf30517c1145..7a1caf5e50771 100644 --- a/x-pack/plugins/index_management/public/application/constants/index.ts +++ b/x-pack/plugins/index_management/public/application/constants/index.ts @@ -17,4 +17,4 @@ export { export const REACT_ROOT_ID = 'indexManagementReactRoot'; -export * from './ilm_url_generator'; +export * from './ilm_locator'; diff --git a/x-pack/plugins/index_management/public/application/mount_management_section.ts b/x-pack/plugins/index_management/public/application/mount_management_section.ts index 074334ed87725..083a8831291dd 100644 --- a/x-pack/plugins/index_management/public/application/mount_management_section.ts +++ b/x-pack/plugins/index_management/public/application/mount_management_section.ts @@ -62,7 +62,7 @@ export async function mountManagementSection( uiSettings, } = core; - const { urlGenerators } = startDependencies.share; + const { url } = startDependencies.share; docTitle.change(PLUGIN.getI18nName(i18n)); breadcrumbService.setup(setBreadcrumbs); @@ -86,7 +86,7 @@ export async function mountManagementSection( history, setBreadcrumbs, uiSettings, - urlGenerators, + url, docLinks, }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index 773ccd91a5fb1..a9258c6a3b10b 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -29,11 +29,11 @@ import { SectionLoading, SectionError, Error, DataHealth } from '../../../../com import { useLoadDataStream } from '../../../../services/api'; import { DeleteDataStreamConfirmationModal } from '../delete_data_stream_confirmation_modal'; import { humanizeTimeStamp } from '../humanize_time_stamp'; -import { useUrlGenerator } from '../../../../services/use_url_generator'; import { getIndexListUri, getTemplateDetailsLink } from '../../../../services/routing'; -import { ILM_PAGES_POLICY_EDIT, ILM_URL_GENERATOR_ID } from '../../../../constants'; +import { ILM_PAGES_POLICY_EDIT } from '../../../../constants'; import { useAppContext } from '../../../../app_context'; import { DataStreamsBadges } from '../data_stream_badges'; +import { useIlmLocator } from '../../../../services/use_ilm_locator'; interface DetailsListProps { details: Array<{ @@ -89,13 +89,7 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ const [isDeleting, setIsDeleting] = useState(false); - const ilmPolicyLink = useUrlGenerator({ - urlGeneratorId: ILM_URL_GENERATOR_ID, - urlGeneratorState: { - page: ILM_PAGES_POLICY_EDIT, - policyName: dataStream?.ilmPolicyName, - }, - }); + const ilmPolicyLink = useIlmLocator(ILM_PAGES_POLICY_EDIT, dataStream?.ilmPolicyName); const { history } = useAppContext(); let content; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx index 2dd2c6e30cfcc..c17ccd9ced932 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx @@ -21,8 +21,8 @@ import { EuiSpacer, } from '@elastic/eui'; import { TemplateDeserialized } from '../../../../../../../common'; -import { ILM_PAGES_POLICY_EDIT, ILM_URL_GENERATOR_ID } from '../../../../../constants'; -import { useUrlGenerator } from '../../../../../services/use_url_generator'; +import { ILM_PAGES_POLICY_EDIT } from '../../../../../constants'; +import { useIlmLocator } from '../../../../../services/use_ilm_locator'; interface Props { templateDetails: TemplateDeserialized; @@ -54,13 +54,7 @@ export const TabSummary: React.FunctionComponent = ({ templateDetails }) const numIndexPatterns = indexPatterns.length; - const ilmPolicyLink = useUrlGenerator({ - urlGeneratorId: ILM_URL_GENERATOR_ID, - urlGeneratorState: { - page: ILM_PAGES_POLICY_EDIT, - policyName: ilmPolicy?.name, - }, - }); + const ilmPolicyLink = useIlmLocator(ILM_PAGES_POLICY_EDIT, ilmPolicy?.name); return ( <> diff --git a/x-pack/plugins/index_management/public/application/services/use_ilm_locator.ts b/x-pack/plugins/index_management/public/application/services/use_ilm_locator.ts new file mode 100644 index 0000000000000..d60cd1cf8aabf --- /dev/null +++ b/x-pack/plugins/index_management/public/application/services/use_ilm_locator.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useLocatorUrl } from '../../../../../../src/plugins/share/public'; +import { useAppContext } from '../app_context'; +import { ILM_LOCATOR_ID } from '../constants'; + +export const useIlmLocator = ( + page: 'policies_list' | 'policy_edit' | 'policy_create', + policyName?: string +): string => { + const ctx = useAppContext(); + const locator = policyName === undefined ? null : ctx.url.locators.get(ILM_LOCATOR_ID)!; + const url = useLocatorUrl(locator, { page, policyName }, {}, [page, policyName]); + + return url; +}; diff --git a/x-pack/plugins/index_management/public/application/services/use_url_generator.ts b/x-pack/plugins/index_management/public/application/services/use_url_generator.ts deleted file mode 100644 index 2d9ab3959d769..0000000000000 --- a/x-pack/plugins/index_management/public/application/services/use_url_generator.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useEffect, useState } from 'react'; -import { - UrlGeneratorContract, - UrlGeneratorId, - UrlGeneratorStateMapping, -} from '../../../../../../src/plugins/share/public'; -import { useAppContext } from '../app_context'; - -export const useUrlGenerator = ({ - urlGeneratorId, - urlGeneratorState, -}: { - urlGeneratorId: UrlGeneratorId; - urlGeneratorState: UrlGeneratorStateMapping[UrlGeneratorId]['State']; -}) => { - const { urlGenerators } = useAppContext(); - const [link, setLink] = useState(); - useEffect(() => { - const updateLink = async (): Promise => { - let urlGenerator: UrlGeneratorContract; - try { - urlGenerator = urlGenerators.getUrlGenerator(urlGeneratorId); - const url = await urlGenerator.createUrl(urlGeneratorState); - setLink(url); - } catch (e) { - // do nothing - } - }; - - updateLink(); - }, [urlGeneratorId, urlGeneratorState, urlGenerators]); - return link; -}; diff --git a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx index 41867053c3a0f..c3327dc3fe85d 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx @@ -36,12 +36,12 @@ export const MetricsAlertDropdown = () => { () => ({ id: 1, title: i18n.translate('xpack.infra.alerting.infrastructureDropdownTitle', { - defaultMessage: 'Infrastructure alerts', + defaultMessage: 'Infrastructure rules', }), items: [ { name: i18n.translate('xpack.infra.alerting.createInventoryAlertButton', { - defaultMessage: 'Create inventory alert', + defaultMessage: 'Create inventory rule', }), onClick: () => setVisibleFlyoutType('inventory'), }, @@ -54,12 +54,12 @@ export const MetricsAlertDropdown = () => { () => ({ id: 2, title: i18n.translate('xpack.infra.alerting.metricsDropdownTitle', { - defaultMessage: 'Metrics alerts', + defaultMessage: 'Metrics rules', }), items: [ { name: i18n.translate('xpack.infra.alerting.createThresholdAlertButton', { - defaultMessage: 'Create threshold alert', + defaultMessage: 'Create threshold rule', }), onClick: () => setVisibleFlyoutType('threshold'), }, @@ -76,7 +76,7 @@ export const MetricsAlertDropdown = () => { const manageAlertsMenuItem = useMemo( () => ({ name: i18n.translate('xpack.infra.alerting.manageAlerts', { - defaultMessage: 'Manage alerts', + defaultMessage: 'Manage rules', }), icon: 'tableOfContents', onClick: manageAlertsLinkProps.onClick, @@ -112,7 +112,7 @@ export const MetricsAlertDropdown = () => { { id: 0, title: i18n.translate('xpack.infra.alerting.alertDropdownTitle', { - defaultMessage: 'Alerts', + defaultMessage: 'Alerts and rules', }), items: firstPanelMenuItems, }, diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx index a6b69a37f780e..c9b6275264f91 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx @@ -17,7 +17,7 @@ export const ManageAlertsContextMenuItem = () => { }); return ( - + ); }; diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx index 66c77fbf875a4..c1733d4af0589 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx @@ -66,13 +66,13 @@ export const AlertDropdown = () => { > , , ]; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx index 94b16448a6b61..ea80bd13e8a4d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx @@ -25,6 +25,7 @@ import { SectionSubtitle, SectionLinks, SectionLink, + ActionMenuDivider, } from '../../../../../../../observability/public'; import { useLinkProps } from '../../../../../hooks/use_link_props'; @@ -173,7 +174,10 @@ export const NodeContextMenu: React.FC = withTheme - + + + +
diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index cf3d8a15b7b65..922b10e8bd2b0 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -123,7 +123,7 @@ const getData = async ( const client = async ( options: CallWithRequestParams ): Promise> => - // @ts-expect-error @elastic/elasticsearch SearchResponse.body.timeout is not required + // @ts-expect-error SearchResponse.body.timeout is optional (await esClient.search(options)).body as InfraDatabaseSearchResponse; const metrics = [ diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts index aa34204b9fb44..1f0f13eeb6ca9 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts @@ -84,7 +84,7 @@ export const logEntrySearchStrategyProvider = ({ tiebreakerField, runtimeMappings, }): IEsSearchRequest => ({ - // @ts-expect-error @elastic/elasticsearch declares indices_boost as Record + // @ts-expect-error `Field` is not assignable to `SearchRequest.docvalue_fields` params: createGetLogEntryQuery( indices, params.logEntryId, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/circle.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/circle.test.tsx index e29bb2ac6e92e..b8c8f6c58f711 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/circle.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/circle.test.tsx @@ -54,9 +54,10 @@ describe('Processor: Circle', () => { // Click submit button with only the type defined await saveNewProcessor(); - // Expect form error as "field" and "shape_type" are required parameters + // Expect form error as "field", "shape_type" and "error_distance" are required parameters expect(form.getErrorsMessages()).toEqual([ 'A field value is required.', + 'An error distance value is required.', 'A shape type value is required.', ]); }); @@ -91,15 +92,15 @@ describe('Processor: Circle', () => { form, } = testBed; - // Add "field" value (required) + // Set required parameters form.setInputValue('fieldNameField.input', 'field_1'); - // Select the shape form.setSelectValue('shapeSelectorField', 'geo_shape'); - // Add "target_field" value - form.setInputValue('targetField.input', 'target_field'); - form.setInputValue('errorDistanceField.input', '10'); + // Set optional parameters + form.setInputValue('targetField.input', 'target_field'); + form.toggleEuiSwitch('ignoreMissingSwitch.input'); + // Save the field with new changes await saveNewProcessor(); @@ -109,6 +110,7 @@ describe('Processor: Circle', () => { error_distance: 10, shape_type: 'geo_shape', target_field: 'target_field', + ignore_missing: true, }); }); }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/circle.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/circle.tsx index 74a7f37d841ae..87e08eaeea6e6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/circle.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/circle.tsx @@ -13,6 +13,7 @@ import { EuiCode } from '@elastic/eui'; import { FIELD_TYPES, fieldValidators, + fieldFormatters, UseField, SelectField, NumericField, @@ -24,13 +25,13 @@ import { FieldNameField } from './common_fields/field_name_field'; import { TargetField } from './common_fields/target_field'; const { emptyField } = fieldValidators; +const { toInt } = fieldFormatters; const fieldsConfig: FieldsConfig = { /* Required fields config */ error_distance: { type: FIELD_TYPES.NUMBER, - deserializer: (v) => (typeof v === 'number' && !isNaN(v) ? v : 1.0), - serializer: Number, + formatters: [toInt], label: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.circleForm.errorDistanceFieldLabel', { @@ -49,18 +50,11 @@ const fieldsConfig: FieldsConfig = { ), validations: [ { - validator: ({ value }) => { - return isNaN(Number(value)) - ? { - message: i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.circleForm.errorDistanceError', - { - defaultMessage: 'An error distance value is required.', - } - ), - } - : undefined; - }, + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.circleForm.errorDistanceError', { + defaultMessage: 'An error distance value is required.', + }) + ), }, ], }, @@ -110,14 +104,14 @@ export const Circle: FunctionComponent = () => { options: [ { value: 'shape', - label: i18n.translate( + text: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.circleForm.shapeTypeShape', { defaultMessage: 'Shape' } ), }, { value: 'geo_shape', - label: i18n.translate( + text: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.circleForm.shapeTypeGeoShape', { defaultMessage: 'Geo-shape' } ), diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index 2f3eb5043d610..c62b10093e6e5 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -113,7 +113,7 @@ export function DimensionContainer({ > {i18n.translate('xpack.lens.configure.configurePanelTitle', { - defaultMessage: '{groupLabel} configuration', + defaultMessage: '{groupLabel}', values: { groupLabel, }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss index 3fafa8b37a42f..a4e22b4ef558c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss @@ -22,5 +22,5 @@ img.lnsChartSwitch__chartIcon { // stylelint-disable-line selector-no-qualifying } .lnsChartSwitch__search { - width: 7 * $euiSizeXXL; + width: 10 * $euiSizeXXL; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 74b47b55fdd0e..b35986c42054d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -141,8 +141,11 @@ export function DimensionEditor(props: DimensionEditorProps) { }; const incompleteInfo = (state.layers[layerId].incompleteColumns ?? {})[columnId]; - const incompleteOperation = incompleteInfo?.operationType; - const incompleteField = incompleteInfo?.sourceField ?? null; + const { + operationType: incompleteOperation, + sourceField: incompleteField = null, + ...incompleteParams + } = incompleteInfo || {}; const ParamEditor = selectedOperationDefinition?.paramEditor; @@ -486,6 +489,7 @@ export function DimensionEditor(props: DimensionEditorProps) { field: currentIndexPattern.getFieldByName(choice.field), visualizationGroups: dimensionGroups, targetGroup: props.groupId, + incompleteParams, }) ); }} @@ -746,7 +750,7 @@ function getErrorMessage( if (selectedColumn && incompleteOperation) { if (input === 'field') { return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', { - defaultMessage: 'To use this function, select a different field.', + defaultMessage: 'This field does not work with the selected function.', }); } return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/math.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/math.tsx index 52522a18604aa..7aae35f496923 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/math.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/math.tsx @@ -48,7 +48,7 @@ export const mathOperation: OperationDefinition { ).toEqual(expect.objectContaining({ columnOrder: ['col1', 'col2', 'col3'] })); }); + it('should inherit filters from the incomplete column when passed', () => { + expect( + insertNewColumn({ + layer: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }, + }, + columnId: 'col2', + indexPattern, + op: 'average', + field: indexPattern.fields[2], + visualizationGroups: [], + incompleteParams: { filter: { language: 'kuery', query: '' }, timeShift: '3d' }, + }) + ).toEqual( + expect.objectContaining({ + columns: expect.objectContaining({ + col2: expect.objectContaining({ + filter: { language: 'kuery', query: '' }, + timeShift: '3d', + }), + }), + }) + ); + }); + describe('inserting a new reference', () => { it('should throw if the required references are impossible to match', () => { // @ts-expect-error this function is not valid diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index f0095b66e2ba6..fd3df9f97cecf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -7,6 +7,7 @@ import { partition, mapValues, pickBy } from 'lodash'; import { CoreStart } from 'kibana/public'; +import { Query } from 'src/plugins/data/common'; import type { FramePublicAPI, OperationMetadata, @@ -18,6 +19,7 @@ import { OperationType, IndexPatternColumn, RequiredReference, + GenericOperationDefinition, } from './definitions'; import type { IndexPattern, @@ -29,6 +31,13 @@ import { getSortScoreByPriority } from './operations'; import { generateId } from '../../id_generator'; import { ReferenceBasedIndexPatternColumn } from './definitions/column_types'; import { FormulaIndexPatternColumn, regenerateLayerFromAst } from './definitions/formula'; +import { TimeScaleUnit } from '../time_scale'; + +interface ColumnAdvancedParams { + filter?: Query | undefined; + timeShift?: string | undefined; + timeScale?: TimeScaleUnit | undefined; +} interface ColumnChange { op: OperationType; @@ -39,6 +48,7 @@ interface ColumnChange { visualizationGroups: VisualizationDimensionGroupConfig[]; targetGroup?: string; shouldResetLabel?: boolean; + incompleteParams?: ColumnAdvancedParams; } interface ColumnCopy { @@ -141,6 +151,24 @@ export function insertOrReplaceColumn(args: ColumnChange): IndexPatternLayer { return insertNewColumn(args); } +function ensureCompatibleParamsAreMoved( + column: T, + referencedOperation: GenericOperationDefinition, + previousColumn: ColumnAdvancedParams +) { + const newColumn = { ...column }; + if (referencedOperation.filterable) { + newColumn.filter = (previousColumn as ReferenceBasedIndexPatternColumn).filter; + } + if (referencedOperation.shiftable) { + newColumn.timeShift = (previousColumn as ReferenceBasedIndexPatternColumn).timeShift; + } + if (referencedOperation.timeScalingMode !== 'disabled') { + newColumn.timeScale = (previousColumn as ReferenceBasedIndexPatternColumn).timeScale; + } + return newColumn; +} + // Insert a column into an empty ID. The field parameter is required when constructing // a field-based operation, but will cause the function to fail for any other type of operation. export function insertNewColumn({ @@ -152,6 +180,7 @@ export function insertNewColumn({ visualizationGroups, targetGroup, shouldResetLabel, + incompleteParams, }: ColumnChange): IndexPatternLayer { const operationDefinition = operationDefinitionMap[op]; @@ -163,7 +192,10 @@ export function insertNewColumn({ throw new Error(`Can't insert a column with an ID that is already in use`); } - const baseOptions = { indexPattern, previousColumn: layer.columns[columnId] }; + const baseOptions = { + indexPattern, + previousColumn: { ...incompleteParams, ...layer.columns[columnId] }, + }; if (operationDefinition.input === 'none' || operationDefinition.input === 'managedReference') { if (field) { @@ -414,15 +446,13 @@ export function replaceColumn({ indexPattern, }); - const column = copyCustomLabel({ ...referenceColumn }, previousColumn); // do not forget to move over also any filter/shift/anything (if compatible) // from the reference definition to the new operation. - if (referencedOperation.filterable) { - column.filter = (previousColumn as ReferenceBasedIndexPatternColumn).filter; - } - if (referencedOperation.shiftable) { - column.timeShift = (previousColumn as ReferenceBasedIndexPatternColumn).timeShift; - } + const column = ensureCompatibleParamsAreMoved( + copyCustomLabel({ ...referenceColumn }, previousColumn), + referencedOperation, + previousColumn as ReferenceBasedIndexPatternColumn + ); tempLayer = { ...tempLayer, @@ -529,15 +559,30 @@ export function replaceColumn({ } if (!field) { + let incompleteColumn: { + operationType: OperationType; + } & ColumnAdvancedParams = { operationType: op }; // if no field is available perform a full clean of the column from the layer if (previousDefinition.input === 'fullReference') { tempLayer = deleteColumn({ layer: tempLayer, columnId, indexPattern }); + const previousReferenceId = (previousColumn as ReferenceBasedIndexPatternColumn) + .references[0]; + const referenceColumn = layer.columns[previousReferenceId]; + if (referenceColumn) { + const referencedOperation = operationDefinitionMap[referenceColumn.operationType]; + + incompleteColumn = ensureCompatibleParamsAreMoved( + incompleteColumn, + referencedOperation, + previousColumn + ); + } } return { ...tempLayer, incompleteColumns: { ...(tempLayer.incompleteColumns ?? {}), - [columnId]: { operationType: op }, + [columnId]: incompleteColumn, }, }; } diff --git a/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx new file mode 100644 index 0000000000000..67e57dadd4935 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { LegendActionProps, SeriesIdentifier } from '@elastic/charts'; +import { EuiPopover } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ComponentType, ReactWrapper } from 'enzyme'; +import type { Datatable } from 'src/plugins/expressions/public'; +import { getLegendAction } from './get_legend_action'; +import { LegendActionPopover } from '../shared_components'; + +const table: Datatable = { + type: 'datatable', + columns: [ + { id: 'a', name: 'A', meta: { type: 'string' } }, + { id: 'b', name: 'B', meta: { type: 'number' } }, + ], + rows: [ + { a: 'Hi', b: 2 }, + { a: 'Test', b: 4 }, + { a: 'Foo', b: 6 }, + ], +}; + +describe('getLegendAction', function () { + let wrapperProps: LegendActionProps; + const Component: ComponentType = getLegendAction(table, jest.fn()); + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapperProps = { + color: 'rgb(109, 204, 177)', + label: 'Bar', + series: ([ + { + specId: 'donut', + key: 'Bar', + }, + ] as unknown) as SeriesIdentifier[], + }; + }); + + it('is not rendered if row does not exist', () => { + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + expect(wrapper.find(EuiPopover).length).toBe(0); + }); + + it('is rendered if row is detected', () => { + const newProps = { + ...wrapperProps, + label: 'Hi', + series: ([ + { + specId: 'donut', + key: 'Hi', + }, + ] as unknown) as SeriesIdentifier[], + }; + wrapper = mountWithIntl(); + expect(wrapper.find(EuiPopover).length).toBe(1); + expect(wrapper.find(EuiPopover).prop('title')).toEqual('Hi, filter options'); + expect(wrapper.find(LegendActionPopover).prop('context')).toEqual({ + data: [ + { + column: 0, + row: 0, + table, + value: 'Hi', + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx new file mode 100644 index 0000000000000..9f16ad863a415 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import type { LegendAction } from '@elastic/charts'; +import type { Datatable } from 'src/plugins/expressions/public'; +import type { LensFilterEvent } from '../types'; +import { LegendActionPopover } from '../shared_components'; + +export const getLegendAction = ( + table: Datatable, + onFilter: (data: LensFilterEvent['data']) => void +): LegendAction => + React.memo(({ series: [pieSeries], label }) => { + const data = table.columns.reduce((acc, { id }, column) => { + const value = pieSeries.key; + const row = table.rows.findIndex((r) => r[id] === value); + if (row > -1) { + acc.push({ + table, + column, + row, + value, + }); + } + + return acc; + }, []); + + if (data.length === 0) { + return null; + } + + const context: LensFilterEvent['data'] = { + data, + }; + + return ; + }); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index 6c1cbe63a5a3e..f329cfe1bb8b9 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -38,6 +38,7 @@ import { SeriesLayer, } from '../../../../../src/plugins/charts/public'; import { LensIconChartDonut } from '../assets/chart_donut'; +import { getLegendAction } from './get_legend_action'; declare global { interface Window { @@ -281,6 +282,7 @@ export function PieComponent( onElementClick={ props.renderMode !== 'noInteractivity' ? onElementClickHandler : undefined } + legendAction={getLegendAction(firstTable, onClickValue)} theme={{ ...chartTheme, background: { diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index cf8536884acdf..c200a18a25caf 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -13,3 +13,4 @@ export { TooltipWrapper } from './tooltip_wrapper'; export * from './coloring'; export { useDebouncedValue } from './debounced_value'; export * from './helpers'; +export { LegendActionPopover } from './legend_action_popover'; diff --git a/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx new file mode 100644 index 0000000000000..e344cb5289f51 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; +import type { LensFilterEvent } from '../types'; +import { desanitizeFilterContext } from '../utils'; + +export interface LegendActionPopoverProps { + /** + * Determines the panels label + */ + label: string; + /** + * Callback on filter value + */ + onFilter: (data: LensFilterEvent['data']) => void; + /** + * Determines the filter event data + */ + context: LensFilterEvent['data']; +} + +export const LegendActionPopover: React.FunctionComponent = ({ + label, + onFilter, + context, +}) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 'main', + title: label, + items: [ + { + name: i18n.translate('xpack.lens.shared.legend.filterForValueButtonAriaLabel', { + defaultMessage: 'Filter for value', + }), + 'data-test-subj': `legend-${label}-filterIn`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(desanitizeFilterContext(context)); + }, + }, + { + name: i18n.translate('xpack.lens.shared.legend.filterOutValueButtonAriaLabel', { + defaultMessage: 'Filter out value', + }), + 'data-test-subj': `legend-${label}-filterOut`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(desanitizeFilterContext({ ...context, negate: true })); + }, + }, + ], + }, + ]; + + const Button = ( +
setPopoverOpen(!popoverOpen)} + onClick={() => setPopoverOpen(!popoverOpen)} + > + +
+ ); + return ( + setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="upLeft" + title={i18n.translate('xpack.lens.shared.legend.filterOptionsLegend', { + defaultMessage: '{legendDataLabel}, filter options', + values: { legendDataLabel: label }, + })} + > + + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index f9b4e33072c81..1f647680408d7 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -7,6 +7,13 @@ exports[`xy_expression XYChart component it renders area 1`] = ` { }); }); + test('onElementClick returns correct context data for date histogram', () => { + const geometry: GeometryValue = { + x: 1585758120000, + y: 1, + accessor: 'y1', + mark: null, + datum: {}, + }; + const series = { + key: 'spec{d}yAccessor{d}splitAccessors{b-2}', + specId: 'd', + yAccessor: 'yAccessorId', + splitAccessors: {}, + seriesKeys: ['yAccessorId'], + }; + + const { args } = sampleArgs(); + + const wrapper = mountWithIntl( + + ); + + wrapper.find(Settings).first().prop('onElementClick')!([ + [geometry, series as XYChartSeriesIdentifier], + ]); + + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: dateHistogramData.tables.timeLayer, + value: 1585758120000, + }, + ], + timeFieldName: 'order_date', + }); + }); + + test('onElementClick returns correct context data for numeric histogram', () => { + const { args } = sampleArgs(); + + const numberLayer: LayerArgs = { + layerId: 'numberLayer', + hide: false, + xAccessor: 'xAccessorId', + yScaleType: 'linear', + xScaleType: 'linear', + isHistogram: true, + seriesType: 'bar_stacked', + accessors: ['yAccessorId'], + palette: mockPaletteOutput, + }; + + const numberHistogramData: LensMultiTable = { + type: 'lens_multitable', + tables: { + numberLayer: { + type: 'datatable', + rows: [ + { + xAccessorId: 5, + yAccessorId: 1, + }, + { + xAccessorId: 7, + yAccessorId: 1, + }, + { + xAccessorId: 8, + yAccessorId: 1, + }, + { + xAccessorId: 10, + yAccessorId: 1, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'bytes', + meta: { type: 'number' }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { type: 'number' }, + }, + ], + }, + }, + dateRange: { + fromDate: new Date('2020-04-01T16:14:16.246Z'), + toDate: new Date('2020-04-01T17:15:41.263Z'), + }, + }; + const geometry: GeometryValue = { + x: 5, + y: 1, + accessor: 'y1', + mark: null, + datum: {}, + }; + const series = { + key: 'spec{d}yAccessor{d}splitAccessors{b-2}', + specId: 'd', + yAccessor: 'yAccessorId', + splitAccessors: {}, + seriesKeys: ['yAccessorId'], + }; + + const wrapper = mountWithIntl( + + ); + + wrapper.find(Settings).first().prop('onElementClick')!([ + [geometry, series as XYChartSeriesIdentifier], + ]); + + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: numberHistogramData.tables.numberLayer, + value: 5, + }, + ], + timeFieldName: undefined, + }); + }); + test('returns correct original data for ordinal x axis with special formatter', () => { const geometry: GeometryValue = { x: 'BAR', y: 1, accessor: 'y1', mark: null, datum: {} }; const series = { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 4cd2b55e8d424..1de5cf6b30533 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -58,6 +58,7 @@ import { fittingFunctionDefinitions, getFitOptions } from './fitting_functions'; import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axes_configuration'; import { getColorAssignments } from './color_assignment'; import { getXDomain, XyEndzones } from './x_domain'; +import { getLegendAction } from './get_legend_action'; declare global { interface Window { @@ -390,6 +391,7 @@ export function XYChart({ ); const xAxisFormatter = formatFactory(xAxisColumn && xAxisColumn.meta?.params); const layersAlreadyFormatted: Record = {}; + // This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers const safeXAccessorLabelRenderer = (value: unknown): string => xAxisColumn && layersAlreadyFormatted[xAxisColumn.id] @@ -560,9 +562,9 @@ export function XYChart({ value: pointValue, }); } - - const xAxisFieldName = table.columns.find((el) => el.id === layer.xAccessor)?.meta?.field; - const timeFieldName = xDomain && xAxisFieldName; + const currentColumnMeta = table.columns.find((el) => el.id === layer.xAccessor)?.meta; + const xAxisFieldName = currentColumnMeta?.field; + const isDateField = currentColumnMeta?.type === 'date'; const context: LensFilterEvent['data'] = { data: points.map((point) => ({ @@ -571,7 +573,7 @@ export function XYChart({ value: point.value, table, })), - timeFieldName, + timeFieldName: xDomain && isDateField ? xAxisFieldName : undefined, }; onClickValue(desanitizeFilterContext(context)); }; @@ -629,6 +631,13 @@ export function XYChart({ xDomain={xDomain} onBrushEnd={renderMode !== 'noInteractivity' ? brushHandler : undefined} onElementClick={renderMode !== 'noInteractivity' ? clickHandler : undefined} + legendAction={getLegendAction( + filteredLayers, + data.tables, + onClickValue, + formatFactory, + layersAlreadyFormatted + )} showLegendExtra={isHistogramViz && valuesInLegend} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx new file mode 100644 index 0000000000000..e4edfe918a242 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx @@ -0,0 +1,232 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { LegendActionProps, SeriesIdentifier } from '@elastic/charts'; +import { EuiPopover } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ComponentType, ReactWrapper } from 'enzyme'; +import type { LayerArgs } from './types'; +import type { LensMultiTable } from '../types'; +import { getLegendAction } from './get_legend_action'; +import { LegendActionPopover } from '../shared_components'; + +const sampleLayer = { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'splitAccessorId', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, +} as LayerArgs; + +const tables = { + first: { + type: 'datatable', + rows: [ + { + xAccessorId: 1585758120000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Accessories", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760700000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585761120000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'order_date per minute', + meta: { + type: 'date', + field: 'order_date', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'date_histogram', + params: { + field: 'order_date', + timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: '1m', + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, + }, + }, + params: { id: 'date', params: { pattern: 'HH:mm' } }, + }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'string', + field: 'category.keyword', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'terms', + params: { + field: 'category.keyword', + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }, + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', + }, + }, + }, + }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'number', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + params: {}, + }, + params: { id: 'number' }, + }, + }, + ], + }, +} as LensMultiTable['tables']; + +describe('getLegendAction', function () { + let wrapperProps: LegendActionProps; + const Component: ComponentType = getLegendAction( + [sampleLayer], + tables, + jest.fn(), + jest.fn(), + {} + ); + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapperProps = { + color: 'rgb(109, 204, 177)', + label: "Women's Accessories", + series: ([ + { + seriesKeys: ["Women's Accessories", 'test'], + }, + ] as unknown) as SeriesIdentifier[], + }; + }); + + it('is not rendered if not layer is detected', () => { + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + expect(wrapper.find(EuiPopover).length).toBe(0); + }); + + it('is rendered if row does not exist', () => { + const newProps = { + ...wrapperProps, + series: ([ + { + seriesKeys: ['test', 'b'], + }, + ] as unknown) as SeriesIdentifier[], + }; + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + expect(wrapper.find(EuiPopover).length).toBe(0); + }); + + it('is rendered if layer is detected', () => { + const newProps = { + ...wrapperProps, + series: ([ + { + seriesKeys: ["Women's Accessories", 'b'], + }, + ] as unknown) as SeriesIdentifier[], + }; + wrapper = mountWithIntl(); + expect(wrapper.find(EuiPopover).length).toBe(1); + expect(wrapper.find(EuiPopover).prop('title')).toEqual("Women's Accessories, filter options"); + expect(wrapper.find(LegendActionPopover).prop('context')).toEqual({ + data: [ + { + column: 1, + row: 1, + table: tables.first, + value: "Women's Accessories", + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx new file mode 100644 index 0000000000000..c99bf948d6e37 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { LegendAction, XYChartSeriesIdentifier } from '@elastic/charts'; +import type { LayerArgs } from './types'; +import type { LensMultiTable, LensFilterEvent, FormatFactory } from '../types'; +import { LegendActionPopover } from '../shared_components'; + +export const getLegendAction = ( + filteredLayers: LayerArgs[], + tables: LensMultiTable['tables'], + onFilter: (data: LensFilterEvent['data']) => void, + formatFactory: FormatFactory, + layersAlreadyFormatted: Record +): LegendAction => + React.memo(({ series: [xySeries] }) => { + const series = xySeries as XYChartSeriesIdentifier; + const layer = filteredLayers.find((l) => + series.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) + ); + + if (!layer || !layer.splitAccessor) { + return null; + } + + const splitLabel = series.seriesKeys[0] as string; + const accessor = layer.splitAccessor; + + const table = tables[layer.layerId]; + const splitColumn = table.columns.find(({ id }) => id === layer.splitAccessor); + const formatter = formatFactory(splitColumn && splitColumn.meta?.params); + + const rowIndex = table.rows.findIndex((row) => { + if (layersAlreadyFormatted[accessor]) { + // stringify the value to compare with the chart value + return formatter.convert(row[accessor]) === splitLabel; + } + return row[accessor] === splitLabel; + }); + + if (rowIndex < 0) return null; + + const data = [ + { + row: rowIndex, + column: table.columns.findIndex((col) => col.id === accessor), + value: accessor ? table.rows[rowIndex][accessor] : splitLabel, + table, + }, + ]; + + const context: LensFilterEvent['data'] = { + data, + }; + + return ( + + ); + }); diff --git a/x-pack/plugins/lens/server/usage/visualization_counts.ts b/x-pack/plugins/lens/server/usage/visualization_counts.ts index f0c48fb1152e8..6e79d5f342377 100644 --- a/x-pack/plugins/lens/server/usage/visualization_counts.ts +++ b/x-pack/plugins/lens/server/usage/visualization_counts.ts @@ -75,7 +75,7 @@ export async function getVisualizationCounts( }, }); - // @ts-expect-error @elastic/elasticsearch no way to declare aggregations for search response + // @ts-expect-error specify aggregations type explicitly const buckets = results.aggregations.groups.buckets; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap index 95921fa61233c..90a3eb98c64a1 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AddLicense component when license is active should display correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; +exports[`AddLicense component when license is active should display correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; -exports[`AddLicense component when license is expired should display with correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; +exports[`AddLicense component when license is expired should display with correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/license_page_header.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/license_page_header.test.js.snap new file mode 100644 index 0000000000000..047e311f3d325 --- /dev/null +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/license_page_header.test.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LicenseStatus component should display display warning is expired 1`] = `"

Your Platinum license has expired

Your license expired on

"`; + +exports[`LicenseStatus component should display normally when license is active 1`] = `"

Your Gold license is active

Your license will expire on October 12, 2099 7:00 PM EST

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap deleted file mode 100644 index 9bd1c878f8679..0000000000000 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LicenseStatus component should display display warning is expired 1`] = `"

Your Platinum license has expired

Your license expired on
"`; - -exports[`LicenseStatus component should display normally when license is active 1`] = `"

Your Gold license is active

Your license will expire on October 12, 2099 7:00 PM EST
"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap index 4d8b653c4b10d..fda479f2888ce 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RequestTrialExtension component should display when enterprise license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when enterprise license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when license is active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when license is active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when platinum license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when platinum license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap index be634a5b4f748..4fa45c4bec5ce 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap index 1cacadb824630..622bff86ead16 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index 9f89179d207e0..29ec3ddbfdc02 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -262,16 +262,18 @@ exports[`UploadLicense should display a modal when license requires acknowledgem uploadLicenseStatus={[Function]} >
@@ -1301,16 +1303,18 @@ exports[`UploadLicense should display an error when ES says license is expired 1 uploadLicenseStatus={[Function]} >
@@ -2031,16 +2035,18 @@ exports[`UploadLicense should display an error when ES says license is invalid 1 uploadLicenseStatus={[Function]} >
@@ -2761,16 +2767,18 @@ exports[`UploadLicense should display an error when submitting invalid JSON 1`] uploadLicenseStatus={[Function]} >
@@ -3491,16 +3499,18 @@ exports[`UploadLicense should display error when ES returns error 1`] = ` uploadLicenseStatus={[Function]} >
diff --git a/x-pack/plugins/license_management/__jest__/license_status.test.js b/x-pack/plugins/license_management/__jest__/license_page_header.test.js similarity index 83% rename from x-pack/plugins/license_management/__jest__/license_status.test.js rename to x-pack/plugins/license_management/__jest__/license_page_header.test.js index 898667e13a1b3..56a71eb8d252e 100644 --- a/x-pack/plugins/license_management/__jest__/license_status.test.js +++ b/x-pack/plugins/license_management/__jest__/license_page_header.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { LicenseStatus } from '../public/application/sections/license_dashboard/license_status'; +import { LicensePageHeader } from '../public/application/sections/license_dashboard/license_page_header'; import { createMockLicense, getComponent } from './util'; describe('LicenseStatus component', () => { @@ -14,7 +14,7 @@ describe('LicenseStatus component', () => { { license: createMockLicense('gold'), }, - LicenseStatus + LicensePageHeader ); expect(rendered.html()).toMatchSnapshot(); }); @@ -23,7 +23,7 @@ describe('LicenseStatus component', () => { { license: createMockLicense('platinum', 0), }, - LicenseStatus + LicensePageHeader ); expect(rendered.html()).toMatchSnapshot(); }); diff --git a/x-pack/plugins/license_management/kibana.json b/x-pack/plugins/license_management/kibana.json index 1f925a453898e..be2e21c7eb41e 100644 --- a/x-pack/plugins/license_management/kibana.json +++ b/x-pack/plugins/license_management/kibana.json @@ -9,6 +9,7 @@ "extraPublicDirs": ["common/constants"], "requiredBundles": [ "telemetryManagementSection", + "esUiShared", "kibanaReact" ] } diff --git a/x-pack/plugins/license_management/public/application/app.js b/x-pack/plugins/license_management/public/application/app.js index 3bfa22dd72921..4b5a6144dbdc9 100644 --- a/x-pack/plugins/license_management/public/application/app.js +++ b/x-pack/plugins/license_management/public/application/app.js @@ -10,7 +10,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { LicenseDashboard, UploadLicense } from './sections'; import { Switch, Route } from 'react-router-dom'; import { APP_PERMISSION } from '../../common/constants'; -import { EuiPageBody, EuiEmptyPrompt, EuiText, EuiLoadingSpinner, EuiCallOut } from '@elastic/eui'; +import { SectionLoading } from '../shared_imports'; +import { EuiPageContent, EuiPageBody, EuiEmptyPrompt } from '@elastic/eui'; export class App extends Component { componentDidMount() { @@ -23,52 +24,50 @@ export class App extends Component { if (permissionsLoading) { return ( - } - body={ - - - - } - data-test-subj="sectionLoading" - /> + + + + + ); } if (permissionsError) { + const error = permissionsError?.data?.message; + return ( - - } - color="danger" - iconType="alert" - > - {permissionsError.data && permissionsError.data.message ? ( -
{permissionsError.data.message}
- ) : null} -
+ + + + + } + body={error ?

{error}

: null} + /> +
); } if (!hasPermission) { return ( - + +

-

+ } body={

@@ -82,7 +81,7 @@ export class App extends Component {

} /> -
+ ); } diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js index 4120b2280a7a6..90de14b167e52 100644 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js @@ -18,6 +18,7 @@ export const AddLicense = ({ uploadPath = `/upload_license` }) => { return ( {} }) => { useEffect(() => { @@ -19,17 +20,19 @@ export const LicenseDashboard = ({ setBreadcrumb, telemetry } = { setBreadcrumb: }); return ( -
- - - - - - - - - - -
+ <> + + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/index.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/index.js similarity index 80% rename from x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/index.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/index.js index efd4da2770db4..303e30040ab50 100644 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/index.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/index.js @@ -5,4 +5,4 @@ * 2.0. */ -export { LicenseStatus } from './license_status.container'; +export { LicensePageHeader } from './license_page_header'; diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/license_page_header.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/license_page_header.js new file mode 100644 index 0000000000000..df41d46ac5789 --- /dev/null +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/license_page_header.js @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useSelector } from 'react-redux'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; + +import { getLicenseState } from '../../../store/reducers/license_management'; + +export const ActiveLicensePageHeader = ({ license, ...props }) => { + return ( + + + + } + description={ + + {license.expirationDate ? ( + {license.expirationDate}
, + }} + /> + ) : ( + + )} + + } + /> + ); +}; + +export const ExpiredLicensePageHeader = ({ license, ...props }) => { + return ( + + + + } + description={ + + {license.expirationDate}, + }} + /> + + } + /> + ); +}; + +export const LicensePageHeader = () => { + const license = useSelector(getLicenseState); + + return ( + <> + {license.isExpired ? ( + + ) : ( + + )} + + + ); +}; diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js deleted file mode 100644 index 01577e79fd6ec..0000000000000 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { LicenseStatus as PresentationComponent } from './license_status'; -import { connect } from 'react-redux'; -import { - getLicense, - getExpirationDateFormatted, - isExpired, -} from '../../../store/reducers/license_management'; -import { i18n } from '@kbn/i18n'; - -const mapStateToProps = (state) => { - const { isActive, type } = getLicense(state); - return { - status: isActive - ? i18n.translate('xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText', { - defaultMessage: 'Active', - }) - : i18n.translate( - 'xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText', - { - defaultMessage: 'Inactive', - } - ), - type, - isExpired: isExpired(state), - expiryDate: getExpirationDateFormatted(state), - }; -}; - -export const LicenseStatus = connect(mapStateToProps)(PresentationComponent); diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js deleted file mode 100644 index 5f7e59bf1ceba..0000000000000 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment } from 'react'; - -import { - EuiIcon, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiTitle, - EuiSpacer, - EuiTextAlign, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export class LicenseStatus extends React.PureComponent { - render() { - const { isExpired, status, type, expiryDate } = this.props; - const typeTitleCase = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase(); - let icon; - let title; - let message; - if (isExpired) { - icon = ; - message = ( - - {expiryDate}, - }} - /> - - ); - title = ( - - ); - } else { - icon = ; - message = expiryDate ? ( - - {expiryDate}, - }} - /> - - ) : ( - - - - ); - title = ( - - ); - } - return ( - - - {icon} - - -

{title}

-
-
-
- - - - {message} -
- ); - } -} diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js index 8c694cf27765a..e578c372b9c9f 100644 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js @@ -37,6 +37,7 @@ export const RequestTrialExtension = ({ shouldShowRequestTrialExtension }) => { return ( {this.acknowledgeModal()} { {this.acknowledgeModal(dependencies!.docLinks)} - - - -

- -

-
+ + + +

+ +

+
- + - {this.acknowledgeModal()} + {this.acknowledgeModal()} - -

- -

-

- {currentLicenseType.toUpperCase()}, - }} - /> -

-
- - - - - - - } - onChange={this.handleFile} + +

+ +

+

+ {currentLicenseType.toUpperCase()}, + }} + /> +

+
+ + + + + + + } + onChange={this.handleFile} + /> + + + + + {shouldShowTelemetryOptIn(telemetry) && ( + + )} + + + + + + + + + + {applying ? ( + -
-
-
- - {shouldShowTelemetryOptIn(telemetry) && ( - - )} - - - - + ) : ( - - - - - {applying ? ( - - ) : ( - - )} - - - -
-
-
- + )} + +
+ + + + ); } } diff --git a/x-pack/plugins/license_management/public/application/store/reducers/license_management.js b/x-pack/plugins/license_management/public/application/store/reducers/license_management.js index 20e31cf89da72..1a985cd8ee623 100644 --- a/x-pack/plugins/license_management/public/application/store/reducers/license_management.js +++ b/x-pack/plugins/license_management/public/application/store/reducers/license_management.js @@ -6,6 +6,10 @@ */ import { combineReducers } from 'redux'; +import { i18n } from '@kbn/i18n'; +import { capitalize } from 'lodash'; +import { createSelector } from 'reselect'; + import { license } from './license'; import { uploadStatus } from './upload_status'; import { startBasicStatus } from './start_basic_license_status'; @@ -135,3 +139,31 @@ export const startBasicLicenseNeedsAcknowledgement = (state) => { export const getStartBasicMessages = (state) => { return state.startBasicStatus.messages; }; + +export const getLicenseState = createSelector( + getLicense, + getExpirationDateFormatted, + isExpired, + (license, expirationDate, isExpired) => { + const { isActive, type } = license; + + return { + type: capitalize(type), + isExpired, + expirationDate, + status: isActive + ? i18n.translate( + 'xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText', + { + defaultMessage: 'active', + } + ) + : i18n.translate( + 'xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText', + { + defaultMessage: 'inactive', + } + ), + }; + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss b/x-pack/plugins/license_management/public/shared_imports.ts similarity index 67% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss rename to x-pack/plugins/license_management/public/shared_imports.ts index abe6c0e0789a8..695432684a660 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss +++ b/x-pack/plugins/license_management/public/shared_imports.ts @@ -5,11 +5,4 @@ * 2.0. */ -.analyticsHeader { - flex-wrap: wrap; - - &__filters.euiPageHeaderSection { - width: 100%; - margin: $euiSizeM 0; - } -} +export { SectionLoading } from '../../../../src/plugins/es_ui_shared/public/'; diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts index ec46038c397e5..212db40f3168c 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts @@ -1697,9 +1697,9 @@ describe('Exception builder helpers', () => { namespaceType: 'single', ruleName: 'rule name', }); - const exceptions = filterExceptionItems([{ ...rest, meta }]); + const exceptions = filterExceptionItems([{ ...rest, entries: [getEntryMatchMock()], meta }]); - expect(exceptions).toEqual([{ ...rest, entries: [], meta: undefined }]); + expect(exceptions).toEqual([{ ...rest, entries: [getEntryMatchMock()], meta: undefined }]); }); }); diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts deleted file mode 100644 index 94a049d10cc45..0000000000000 --- a/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SavedObjectsClientContract } from 'kibana/server'; -import uuid from 'uuid'; -import { Version } from '@kbn/securitysolution-io-ts-types'; -import type { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { getSavedObjectType } from '@kbn/securitysolution-list-utils'; -import { - ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_NAME, -} from '@kbn/securitysolution-list-constants'; - -import { ExceptionListSoSchema } from '../../schemas/saved_objects'; - -import { transformSavedObjectToExceptionList } from './utils'; - -interface CreateEndpointEventFiltersListOptions { - savedObjectsClient: SavedObjectsClientContract; - user: string; - tieBreaker?: string; - version: Version; -} - -/** - * Creates the Endpoint Trusted Apps agnostic list if it does not yet exist - * - * @param savedObjectsClient - * @param user - * @param tieBreaker - * @param version - */ -export const createEndpointEventFiltersList = async ({ - savedObjectsClient, - user, - tieBreaker, - version, -}: CreateEndpointEventFiltersListOptions): Promise => { - const savedObjectType = getSavedObjectType({ namespaceType: 'agnostic' }); - const dateNow = new Date().toISOString(); - try { - const savedObject = await savedObjectsClient.create( - savedObjectType, - { - comments: undefined, - created_at: dateNow, - created_by: user, - description: ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, - entries: undefined, - immutable: false, - item_id: undefined, - list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, - list_type: 'list', - meta: undefined, - name: ENDPOINT_EVENT_FILTERS_LIST_NAME, - os_types: [], - tags: [], - tie_breaker_id: tieBreaker ?? uuid.v4(), - type: 'endpoint_events', - updated_by: user, - version, - }, - { - // We intentionally hard coding the id so that there can only be one Event Filters list within the space - id: ENDPOINT_EVENT_FILTERS_LIST_ID, - } - ); - - return transformSavedObjectToExceptionList({ savedObject }); - } catch (err) { - if (savedObjectsClient.errors.isConflictError(err)) { - return null; - } else { - throw err; - } - } -}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 4ccff2dd000b9..77e82bf0f7578 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -54,7 +54,6 @@ import { } from './find_exception_list_items'; import { createEndpointList } from './create_endpoint_list'; import { createEndpointTrustedAppsList } from './create_endpoint_trusted_apps_list'; -import { createEndpointEventFiltersList } from './create_endoint_event_filters_list'; export class ExceptionListClient { private readonly user: string; @@ -120,18 +119,6 @@ export class ExceptionListClient { }); }; - /** - * Create the Endpoint Event Filters Agnostic list if it does not yet exist (`null` is returned if it does exist) - */ - public createEndpointEventFiltersList = async (): Promise => { - const { savedObjectsClient, user } = this; - return createEndpointEventFiltersList({ - savedObjectsClient, - user, - version: 1, - }); - }; - /** * This is the same as "createListItem" except it applies specifically to the agnostic endpoint list and will * auto-call the "createEndpointList" for you so that you have the best chance of the agnostic endpoint diff --git a/x-pack/plugins/monitoring/public/alerts/configuration.tsx b/x-pack/plugins/monitoring/public/alerts/configuration.tsx index 5416095671d71..7825fe8e20617 100644 --- a/x-pack/plugins/monitoring/public/alerts/configuration.tsx +++ b/x-pack/plugins/monitoring/public/alerts/configuration.tsx @@ -32,7 +32,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.disableAlert.errorTitle', { - defaultMessage: `Unable to disable alert`, + defaultMessage: `Unable to disable rule`, }), text: err.message, }); @@ -46,7 +46,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.enableAlert.errorTitle', { - defaultMessage: `Unable to enable alert`, + defaultMessage: `Unable to enable rule`, }), text: err.message, }); @@ -60,7 +60,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.muteAlert.errorTitle', { - defaultMessage: `Unable to mute alert`, + defaultMessage: `Unable to mute rule`, }), text: err.message, }); @@ -74,7 +74,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.ummuteAlert.errorTitle', { - defaultMessage: `Unable to unmute alert`, + defaultMessage: `Unable to unmute rule`, }), text: err.message, }); @@ -112,7 +112,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { }} > {i18n.translate('xpack.monitoring.alerts.panel.editAlert', { - defaultMessage: `Edit alert`, + defaultMessage: `Edit rule`, })}
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts index 213e73a4b9534..a96a7454ea744 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts @@ -95,7 +95,7 @@ export async function fetchCCRReadExceptions( const { body: response } = await esClient.search(params); const stats: CCRReadExceptionsStats[] = []; - // @ts-expect-error @elastic/elasticsearch Aggregate does not specify buckets + // @ts-expect-error declare aggegations type explicitly const { buckets: remoteClusterBuckets = [] } = response.aggregations?.remote_clusters; if (!remoteClusterBuckets?.length) { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.test.ts index 0fb9dd5298e9e..9cb773c81923b 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.test.ts @@ -25,7 +25,7 @@ describe('fetchCpuUsageNodeStats', () => { it('fetch normal stats', async () => { esClient.search.mockReturnValue( - // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { @@ -79,7 +79,7 @@ describe('fetchCpuUsageNodeStats', () => { it('fetch container stats', async () => { esClient.search.mockReturnValue( - // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { @@ -146,7 +146,7 @@ describe('fetchCpuUsageNodeStats', () => { it('fetch properly return ccs', async () => { esClient.search.mockReturnValue( - // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.test.ts index 8faf79fc4b59c..4766400891af5 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.test.ts @@ -25,7 +25,7 @@ describe('fetchDiskUsageNodeStats', () => { it('fetch normal stats', async () => { esClient.search.mockReturnValue( - // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts index a51dccd727966..2e8b5c7478e15 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts @@ -101,7 +101,7 @@ export async function fetchDiskUsageNodeStats( const { body: response } = await esClient.search(params); const stats: AlertDiskUsageNodeStats[] = []; - // @ts-expect-error @elastic/elasticsearch Aggregate does not define buckets + // @ts-expect-error declare type for aggregations explicitly const { buckets: clusterBuckets } = response.aggregations?.clusters; if (!clusterBuckets?.length) { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts index aab3f0101ef83..117894c0d823b 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts @@ -105,7 +105,7 @@ export async function fetchIndexShardSize( }; const { body: response } = await esClient.search(params); - // @ts-expect-error @elastic/elasticsearch Aggregate does not specify buckets + // @ts-expect-error declare aggegations type explicitly const { buckets: clusterBuckets } = response.aggregations?.clusters; const stats: IndexShardSizeStats[] = []; if (!clusterBuckets?.length) { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.test.ts index 2b966b16f2f5c..f9a03bb73d5fc 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.test.ts @@ -23,7 +23,7 @@ describe('fetchKibanaVersions', () => { it('fetch as expected', async () => { esClient.search.mockReturnValue( - // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { index: { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.test.ts index d7d4e6531f58e..5732fc00f009b 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.test.ts @@ -23,7 +23,7 @@ describe('fetchLogstashVersions', () => { it('fetch as expected', async () => { esClient.search.mockReturnValue( - // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { index: { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts index 245838541d435..46bb9c794a6a6 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts @@ -94,7 +94,7 @@ export async function fetchMemoryUsageNodeStats( const { body: response } = await esClient.search(params); const stats: AlertMemoryUsageNodeStats[] = []; - // @ts-expect-error @elastic/elasticsearch Aggregate does not define buckets + // @ts-expect-error declare type for aggregations explicitly const { buckets: clusterBuckets } = response.aggregations?.clusters; if (!clusterBuckets?.length) { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts index c8d15acf8ff73..980adb009ff8f 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts @@ -56,7 +56,7 @@ describe('fetchMissingMonitoringData', () => { ]; esClient.search.mockReturnValue( - // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { @@ -115,7 +115,7 @@ describe('fetchMissingMonitoringData', () => { }, ]; esClient.search.mockReturnValue( - // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_nodes_from_cluster_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_nodes_from_cluster_stats.ts index d1a343b9b3eef..5f867ca5b6edf 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_nodes_from_cluster_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_nodes_from_cluster_stats.ts @@ -90,7 +90,7 @@ export async function fetchNodesFromClusterStats( const { body: response } = await esClient.search(params); const nodes: AlertClusterStatsNodes[] = []; - // @ts-expect-error @elastic/elasticsearch Aggregate does not define buckets + // @ts-expect-error declare type for aggregations explicitly const clusterBuckets = response.aggregations?.clusters?.buckets; if (!clusterBuckets?.length) { return nodes; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts index db5943ca67031..954ec3877144f 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts @@ -96,7 +96,7 @@ export async function fetchThreadPoolRejectionStats( const { body: response } = await esClient.search(params); const stats: AlertThreadPoolRejectionsStats[] = []; - // @ts-expect-error @elastic/elasticsearch Aggregate does not specify buckets + // @ts-expect-error declare type for aggregations explicitly const { buckets: clusterBuckets } = response.aggregations?.clusters; if (!clusterBuckets?.length) { diff --git a/x-pack/plugins/observability/public/components/app/cases/all_cases/index.tsx b/x-pack/plugins/observability/public/components/app/cases/all_cases/index.tsx index 1636d08aa56e4..29a16590f3eb3 100644 --- a/x-pack/plugins/observability/public/components/app/cases/all_cases/index.tsx +++ b/x-pack/plugins/observability/public/components/app/cases/all_cases/index.tsx @@ -28,19 +28,18 @@ interface AllCasesProps { export const AllCases = React.memo(({ userCanCrud }) => { const { cases: casesUi, - application: { navigateToApp }, + application: { getUrlForApp, navigateToUrl }, } = useKibana().services; const { formatUrl } = useFormatUrl(CASES_APP_ID); + const casesUrl = getUrlForApp(CASES_APP_ID); return casesUi.getAllCases({ caseDetailsNavigation: { href: ({ detailName, subCaseId }: AllCasesNavProps) => { return formatUrl(getCaseDetailsUrl({ id: detailName, subCaseId })); }, onClick: async ({ detailName, subCaseId, search }: AllCasesNavProps) => - navigateToApp(`${CASES_APP_ID}`, { - path: getCaseDetailsUrl({ id: detailName, subCaseId }), - }), + navigateToUrl(`${casesUrl}${getCaseDetailsUrl({ id: detailName, subCaseId })}`), }, configureCasesNavigation: { href: formatUrl(getConfigureCasesUrl()), @@ -48,9 +47,7 @@ export const AllCases = React.memo(({ userCanCrud }) => { if (ev != null) { ev.preventDefault(); } - return navigateToApp(`${CASES_APP_ID}`, { - path: getConfigureCasesUrl(), - }); + return navigateToUrl(`${casesUrl}${getConfigureCasesUrl()}`); }, }, createCaseNavigation: { @@ -59,9 +56,7 @@ export const AllCases = React.memo(({ userCanCrud }) => { if (ev != null) { ev.preventDefault(); } - return navigateToApp(`${CASES_APP_ID}`, { - path: getCreateCaseUrl(), - }); + return navigateToUrl(`${casesUrl}${getCreateCaseUrl()}`); }, }, disableAlerts: true, diff --git a/x-pack/plugins/observability/public/components/app/cases/case_view/index.tsx b/x-pack/plugins/observability/public/components/app/cases/case_view/index.tsx index 728333ac8c544..07d8019153a06 100644 --- a/x-pack/plugins/observability/public/components/app/cases/case_view/index.tsx +++ b/x-pack/plugins/observability/public/components/app/cases/case_view/index.tsx @@ -42,8 +42,10 @@ export interface CaseProps extends Props { export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) => { const [caseTitle, setCaseTitle] = useState(null); - const { cases: casesUi, application } = useKibana().services; - const { navigateToApp } = application; + const { + cases: casesUi, + application: { getUrlForApp, navigateToUrl }, + } = useKibana().services; const allCasesLink = getCaseUrl(); const { formatUrl } = useFormatUrl(CASES_APP_ID); const href = formatUrl(allCasesLink); @@ -79,6 +81,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = [caseId, formatUrl, subCaseId] ); + const casesUrl = getUrlForApp(CASES_APP_ID); return casesUi.getCaseView({ allCasesNavigation: { href: allCasesHref, @@ -86,9 +89,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = if (ev != null) { ev.preventDefault(); } - return navigateToApp(`${CASES_APP_ID}`, { - path: allCasesLink, - }); + return navigateToUrl(casesUrl); }, }, caseDetailsNavigation: { @@ -97,9 +98,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = if (ev != null) { ev.preventDefault(); } - return navigateToApp(`${CASES_APP_ID}`, { - path: getCaseDetailsUrl({ id: caseId }), - }); + return navigateToUrl(`${casesUrl}${getCaseDetailsUrl({ id: caseId })}`); }, }, caseId, @@ -109,9 +108,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = if (ev != null) { ev.preventDefault(); } - return navigateToApp(`${CASES_APP_ID}`, { - path: configureCasesLink, - }); + return navigateToUrl(`${casesUrl}${configureCasesLink}`); }, }, getCaseDetailHrefWithCommentId, diff --git a/x-pack/plugins/observability/public/components/app/cases/create/index.test.tsx b/x-pack/plugins/observability/public/components/app/cases/create/index.test.tsx index ec7511836328b..6dae88733fd49 100644 --- a/x-pack/plugins/observability/public/components/app/cases/create/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/cases/create/index.test.tsx @@ -12,7 +12,7 @@ import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_rea import { Create } from '.'; import { useKibana } from '../../../../utils/kibana_react'; import { basicCase } from '../../../../../../cases/public/containers/mock'; -import { CASES_APP_ID, CASES_OWNER } from '../constants'; +import { CASES_OWNER } from '../constants'; import { Case } from '../../../../../../cases/common'; import { getCaseDetailsUrl } from '../../../../pages/cases/links'; @@ -20,7 +20,8 @@ jest.mock('../../../../utils/kibana_react'); describe('Create case', () => { const mockCreateCase = jest.fn(); - const mockNavigateToApp = jest.fn(); + const mockNavigateToUrl = jest.fn(); + const mockCasesUrl = 'https://elastic.co/app/observability/cases'; beforeEach(() => { jest.resetAllMocks(); (useKibana as jest.Mock).mockReturnValue({ @@ -28,7 +29,7 @@ describe('Create case', () => { cases: { getCreateCase: mockCreateCase, }, - application: { navigateToApp: mockNavigateToApp }, + application: { navigateToUrl: mockNavigateToUrl, getUrlForApp: () => mockCasesUrl }, }, }); }); @@ -52,7 +53,7 @@ describe('Create case', () => { onCancel(); }, }, - application: { navigateToApp: mockNavigateToApp }, + application: { navigateToUrl: mockNavigateToUrl, getUrlForApp: () => mockCasesUrl }, }, }); mount( @@ -61,7 +62,7 @@ describe('Create case', () => { ); - await waitFor(() => expect(mockNavigateToApp).toHaveBeenCalledWith(`${CASES_APP_ID}`)); + await waitFor(() => expect(mockNavigateToUrl).toHaveBeenCalledWith(`${mockCasesUrl}`)); }); it('should redirect to new case when posting the case', async () => { @@ -72,7 +73,7 @@ describe('Create case', () => { onSuccess(basicCase); }, }, - application: { navigateToApp: mockNavigateToApp }, + application: { navigateToUrl: mockNavigateToUrl, getUrlForApp: () => mockCasesUrl }, }, }); mount( @@ -82,9 +83,10 @@ describe('Create case', () => { ); await waitFor(() => - expect(mockNavigateToApp).toHaveBeenNthCalledWith(1, `${CASES_APP_ID}`, { - path: getCaseDetailsUrl({ id: basicCase.id }), - }) + expect(mockNavigateToUrl).toHaveBeenNthCalledWith( + 1, + `${mockCasesUrl}${getCaseDetailsUrl({ id: basicCase.id })}` + ) ); }); }); diff --git a/x-pack/plugins/observability/public/components/app/cases/create/index.tsx b/x-pack/plugins/observability/public/components/app/cases/create/index.tsx index d7e2daea2490b..a3ed234147314 100644 --- a/x-pack/plugins/observability/public/components/app/cases/create/index.tsx +++ b/x-pack/plugins/observability/public/components/app/cases/create/index.tsx @@ -15,17 +15,18 @@ import { CASES_APP_ID, CASES_OWNER } from '../constants'; export const Create = React.memo(() => { const { cases, - application: { navigateToApp }, + application: { getUrlForApp, navigateToUrl }, } = useKibana().services; + const casesUrl = getUrlForApp(CASES_APP_ID); const onSuccess = useCallback( - async ({ id }) => - navigateToApp(`${CASES_APP_ID}`, { - path: getCaseDetailsUrl({ id }), - }), - [navigateToApp] + async ({ id }) => navigateToUrl(`${casesUrl}${getCaseDetailsUrl({ id })}`), + [casesUrl, navigateToUrl] ); - const handleSetIsCancel = useCallback(() => navigateToApp(`${CASES_APP_ID}`), [navigateToApp]); + const handleSetIsCancel = useCallback(() => navigateToUrl(`${casesUrl}`), [ + casesUrl, + navigateToUrl, + ]); return ( diff --git a/x-pack/plugins/observability/public/pages/cases/case_details.tsx b/x-pack/plugins/observability/public/pages/cases/case_details.tsx index 78f1cb313ea9b..6adf5ad286808 100644 --- a/x-pack/plugins/observability/public/pages/cases/case_details.tsx +++ b/x-pack/plugins/observability/public/pages/cases/case_details.tsx @@ -16,7 +16,7 @@ import { CaseCallOut, permissionsReadOnlyErrorMessage } from '../../components/a export const CaseDetailsPage = React.memo(() => { const { - application: { navigateToApp }, + application: { getUrlForApp, navigateToUrl }, } = useKibana().services; const userPermissions = useGetUserCasesPermissions(); const { detailName: caseId, subCaseId } = useParams<{ @@ -24,8 +24,9 @@ export const CaseDetailsPage = React.memo(() => { subCaseId?: string; }>(); + const casesUrl = getUrlForApp(CASES_APP_ID); if (userPermissions != null && !userPermissions.read) { - navigateToApp(`${CASES_APP_ID}`); + navigateToUrl(casesUrl); return null; } diff --git a/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx b/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx index 2986c1ff34e11..a4df4855b0204 100644 --- a/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx +++ b/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx @@ -23,22 +23,23 @@ const ButtonEmpty = styled(EuiButtonEmpty)` function ConfigureCasesPageComponent() { const { cases, - application: { navigateToApp }, + application: { getUrlForApp, navigateToUrl }, } = useKibana().services; + const casesUrl = getUrlForApp(CASES_APP_ID); const userPermissions = useGetUserCasesPermissions(); const { ObservabilityPageTemplate } = usePluginContext(); const onClickGoToCases = useCallback( async (ev) => { ev.preventDefault(); - return navigateToApp(`${CASES_APP_ID}`); + return navigateToUrl(casesUrl); }, - [navigateToApp] + [casesUrl, navigateToUrl] ); const { formatUrl } = useFormatUrl(CASES_APP_ID); const href = formatUrl(getCaseUrl()); useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.configure]); if (userPermissions != null && !userPermissions.read) { - navigateToApp(`${CASES_APP_ID}`); + navigateToUrl(casesUrl); return null; } diff --git a/x-pack/plugins/observability/public/pages/cases/create_case.tsx b/x-pack/plugins/observability/public/pages/cases/create_case.tsx index 11f6d62da6103..96ed59734edda 100644 --- a/x-pack/plugins/observability/public/pages/cases/create_case.tsx +++ b/x-pack/plugins/observability/public/pages/cases/create_case.tsx @@ -25,22 +25,23 @@ export const CreateCasePage = React.memo(() => { const userPermissions = useGetUserCasesPermissions(); const { ObservabilityPageTemplate } = usePluginContext(); const { - application: { navigateToApp }, + application: { getUrlForApp, navigateToUrl }, } = useKibana().services; + const casesUrl = getUrlForApp(CASES_APP_ID); const goTo = useCallback( async (ev) => { ev.preventDefault(); - return navigateToApp(CASES_APP_ID); + return navigateToUrl(casesUrl); }, - [navigateToApp] + [casesUrl, navigateToUrl] ); const { formatUrl } = useFormatUrl(CASES_APP_ID); const href = formatUrl(getCaseUrl()); useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.create]); if (userPermissions != null && !userPermissions.crud) { - navigateToApp(`${CASES_APP_ID}`); + navigateToUrl(casesUrl); return null; } diff --git a/x-pack/plugins/observability/public/pages/overview/empty_section.ts b/x-pack/plugins/observability/public/pages/overview/empty_section.ts index 40b1157b29e35..2747b2ecdebc9 100644 --- a/x-pack/plugins/observability/public/pages/overview/empty_section.ts +++ b/x-pack/plugins/observability/public/pages/overview/empty_section.ts @@ -97,7 +97,7 @@ export const getEmptySections = ({ core }: { core: CoreStart }): ISection[] => { 'Are 503 errors stacking up? Are services responding? Is CPU and RAM utilization jumping? See warnings as they happen—not as part of the post-mortem.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.alert.link', { - defaultMessage: 'Create alert', + defaultMessage: 'Create rule', }), href: core.http.basePath.prepend( '/app/management/insightsAndAlerting/triggersActions/alerts' diff --git a/x-pack/plugins/osquery/server/usage/fetchers.test.ts b/x-pack/plugins/osquery/server/usage/fetchers.test.ts new file mode 100644 index 0000000000000..13da639e2c72d --- /dev/null +++ b/x-pack/plugins/osquery/server/usage/fetchers.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { extractBeatUsageMetrics } from './fetchers'; + +describe('extractBeatUsageMetrics', () => { + it('should not blow when no values are supplied for the aggregations', () => { + expect(extractBeatUsageMetrics({})).toEqual({ + memory: { + rss: {}, + }, + cpu: {}, + }); + }); + + it('should not blow when some values are missing from the aggregations', () => { + expect( + extractBeatUsageMetrics({ + aggregations: { + lastDay: { + max_rss: { + value: 1, + }, + }, + }, + }) + ).toEqual({ + memory: { + rss: { + max: 1, + }, + }, + cpu: {}, + }); + }); + + it('should pick out all the max/avg/latest for memory/cpu', () => { + expect( + extractBeatUsageMetrics({ + aggregations: { + lastDay: { + max_rss: { + value: 1, + }, + avg_rss: { + value: 1, + }, + max_cpu: { + value: 2, + }, + avg_cpu: { + value: 2, + }, + latest: { + hits: { + total: 1, + hits: [ + { + _index: '', + _id: '', + _source: { + monitoring: { + metrics: { + beat: { + cpu: { + total: { + time: { + ms: 2, + }, + }, + }, + memstats: { + rss: 1, + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, + }, + }) + ).toEqual({ + memory: { + rss: { + max: 1, + avg: 1, + latest: 1, + }, + }, + cpu: { + max: 2, + avg: 2, + latest: 2, + }, + }); + }); +}); diff --git a/x-pack/plugins/osquery/server/usage/fetchers.ts b/x-pack/plugins/osquery/server/usage/fetchers.ts index 5f5b282331bec..6a4236b5adccd 100644 --- a/x-pack/plugins/osquery/server/usage/fetchers.ts +++ b/x-pack/plugins/osquery/server/usage/fetchers.ts @@ -9,6 +9,7 @@ import { AggregationsSingleBucketAggregate, AggregationsTopHitsAggregate, AggregationsValueAggregate, + SearchResponse, } from '@elastic/elasticsearch/api/types'; import { PackagePolicyServiceInterface } from '../../../fleet/server'; import { getRouteMetric } from '../routes/usage'; @@ -133,6 +134,46 @@ export async function getLiveQueryUsage( return result; } +export function extractBeatUsageMetrics( + metricResponse: Pick, 'aggregations'> +) { + const lastDayAggs = metricResponse.aggregations?.lastDay as AggregationsSingleBucketAggregate; + const result: BeatMetricsUsage = { + memory: { + rss: {}, + }, + cpu: {}, + }; + + if (lastDayAggs) { + if ('max_rss' in lastDayAggs) { + result.memory.rss.max = (lastDayAggs.max_rss as AggregationsValueAggregate).value; + } + + if ('avg_rss' in lastDayAggs) { + result.memory.rss.avg = (lastDayAggs.max_rss as AggregationsValueAggregate).value; + } + + if ('max_cpu' in lastDayAggs) { + result.cpu.max = (lastDayAggs.max_cpu as AggregationsValueAggregate).value; + } + + if ('avg_cpu' in lastDayAggs) { + result.cpu.avg = (lastDayAggs.max_cpu as AggregationsValueAggregate).value; + } + + if ('latest' in lastDayAggs) { + const latest = (lastDayAggs.latest as AggregationsTopHitsAggregate).hits.hits[0]?._source + ?.monitoring.metrics.beat; + if (latest) { + result.cpu.latest = latest.cpu.total.time.ms; + result.memory.rss.latest = latest.memstats.rss; + } + } + } + return result; +} + export async function getBeatUsage(esClient: ElasticsearchClient) { const { body: metricResponse } = await esClient.search({ body: { @@ -186,38 +227,6 @@ export async function getBeatUsage(esClient: ElasticsearchClient) { }, index: METRICS_INDICES, }); - const lastDayAggs = metricResponse.aggregations?.lastDay as AggregationsSingleBucketAggregate; - const result: BeatMetricsUsage = { - memory: { - rss: {}, - }, - cpu: {}, - }; - - if ('max_rss' in lastDayAggs) { - result.memory.rss.max = (lastDayAggs.max_rss as AggregationsValueAggregate).value; - } - if ('avg_rss' in lastDayAggs) { - result.memory.rss.avg = (lastDayAggs.max_rss as AggregationsValueAggregate).value; - } - - if ('max_cpu' in lastDayAggs) { - result.cpu.max = (lastDayAggs.max_cpu as AggregationsValueAggregate).value; - } - - if ('avg_cpu' in lastDayAggs) { - result.cpu.avg = (lastDayAggs.max_cpu as AggregationsValueAggregate).value; - } - - if ('latest' in lastDayAggs) { - const latest = (lastDayAggs.latest as AggregationsTopHitsAggregate).hits.hits[0]?._source - ?.monitoring.metrics.beat; - if (latest) { - result.cpu.latest = latest.cpu.total.time.ms; - result.memory.rss.latest = latest.memstats.rss; - } - } - - return result; + return extractBeatUsageMetrics(metricResponse); } diff --git a/x-pack/plugins/security/common/model/user.ts b/x-pack/plugins/security/common/model/user.ts index 3f4787b207f88..2bcea659699cb 100644 --- a/x-pack/plugins/security/common/model/user.ts +++ b/x-pack/plugins/security/common/model/user.ts @@ -7,8 +7,8 @@ export interface User { username: string; - email: string; - full_name: string; + email?: string; + full_name?: string; roles: readonly string[]; enabled: boolean; metadata?: { diff --git a/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx b/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx index 29d87e31797cc..8101c09d64907 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx @@ -41,8 +41,8 @@ export const THROTTLE_USERS_WAIT = 10000; export interface UserFormValues { username?: string; - full_name: string; - email: string; + full_name?: string; + email?: string; password?: string; confirm_password?: string; roles: readonly string[]; diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts index 4cbca1c70f507..1707ca710aaf8 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts @@ -224,7 +224,7 @@ export class APIKeys { try { result = ( await this.clusterClient.asInternalUser.security.grantApiKey({ - // @ts-expect-error @elastic/elasticsearch api_key.role_descriptors + // @ts-expect-error @elastic/elasticsearch api_key.role_descriptors doesn't support `Record` body: params, }) ).body; diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index c7c0edcf1e9e1..f6d9af24ee1ad 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -117,7 +117,7 @@ export abstract class BaseAuthenticationProvider { */ protected async getUser(request: KibanaRequest, authHeaders: Headers = {}) { return this.authenticationInfoToAuthenticatedUser( - // @ts-expect-error @elastic/elasticsearch `AuthenticateResponse` type doesn't define `authentication_type` and `enabled`. + // @ts-expect-error Metadata is defined as Record ( await this.options.client .asScoped({ headers: { ...request.headers, ...authHeaders } }) diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index 43338a8f6400f..fae0d7ca69038 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -84,7 +84,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Get token API request to Elasticsearch successful'); return AuthenticationResult.succeeded( this.authenticationInfoToAuthenticatedUser( - // @ts-expect-error @elastic/elasticsearch GetUserAccessTokenResponse declares authentication: string, but expected AuthenticatedUser + // @ts-expect-error @elastic/elasticsearch metadata defined as Record; authenticationInfo as AuthenticationInfo ), { diff --git a/x-pack/plugins/security/server/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts index 1adbb2dc66533..47051cc08da23 100644 --- a/x-pack/plugins/security/server/authentication/tokens.ts +++ b/x-pack/plugins/security/server/authentication/tokens.ts @@ -73,7 +73,7 @@ export class Tokens { return { accessToken, refreshToken, - // @ts-expect-error @elastic/elasticsearch declared GetUserAccessTokenResponse.authentication: string + // @ts-expect-error @elastic/elasticsearch user metadata defined as Record authenticationInfo: authenticationInfo as AuthenticationInfo, }; } catch (err) { diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.ts index 01d32f7fb8233..075a8d133f1e6 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.ts @@ -32,7 +32,7 @@ export function defineGetRolesRoutes({ router, authz }: RouteDefinitionParams) { if (elasticsearchRole) { return response.ok({ body: transformElasticsearchRoleToRole( - // @ts-expect-error @elastic/elasticsearch `XPackRole` type doesn't define `applications` and `transient_metadata`. + // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` elasticsearchRole, request.params.name, authz.applicationName diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts index 4d458be4e332f..be0880a06d59d 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts @@ -27,7 +27,7 @@ export function defineGetAllRolesRoutes({ router, authz }: RouteDefinitionParams body: Object.entries(elasticsearchRoles) .map(([roleName, elasticsearchRole]) => transformElasticsearchRoleToRole( - // @ts-expect-error @elastic/elasticsearch `XPackRole` type doesn't define `applications` and `transient_metadata`. + // @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[] elasticsearchRole, roleName, authz.applicationName diff --git a/x-pack/plugins/security/server/routes/role_mapping/get.ts b/x-pack/plugins/security/server/routes/role_mapping/get.ts index 67cd8975b76eb..257b4210b13f7 100644 --- a/x-pack/plugins/security/server/routes/role_mapping/get.ts +++ b/x-pack/plugins/security/server/routes/role_mapping/get.ts @@ -36,7 +36,7 @@ export function defineRoleMappingGetRoutes(params: RouteDefinitionParams) { return { name, ...mapping, - // @ts-expect-error @elastic/elasticsearch `XPackRoleMapping` type doesn't define `role_templates` property. + // @ts-expect-error @elastic/elasticsearch `SecurityRoleMapping` doeesn't contain `role_templates` role_templates: (mapping.role_templates || []).map((entry: RoleTemplate) => { return { ...entry, diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts index 7f0016e39ff88..3f3209b52120e 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts @@ -19,8 +19,7 @@ import { createTimeline } from '../../tasks/api_calls/timelines'; import { cleanKibana } from '../../tasks/common'; import { createCase } from '../../tasks/api_calls/cases'; -// TODO: enable once attach timeline to cases is re-enabled -describe.skip('attach timeline to case', () => { +describe('attach timeline to case', () => { context('without cases created', () => { beforeEach(() => { cleanKibana(); diff --git a/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts index dc5b247e3ec43..78ee3fdcdcdd5 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts @@ -15,6 +15,8 @@ import { OVERVIEW_URL } from '../../urls/navigation'; import overviewFixture from '../../fixtures/overview_search_strategy.json'; import emptyInstance from '../../fixtures/empty_instance.json'; import { cleanKibana } from '../../tasks/common'; +import { createTimeline, favoriteTimeline } from '../../tasks/api_calls/timelines'; +import { timeline } from '../../objects/timeline'; describe('Overview Page', () => { before(() => { @@ -48,4 +50,21 @@ describe('Overview Page', () => { cy.get(OVERVIEW_EMPTY_PAGE).should('be.visible'); }); }); + + describe('Favorite Timelines', () => { + it('should appear on overview page', () => { + createTimeline(timeline) + .then((response) => response.body.data.persistTimeline.timeline.savedObjectId) + .then((timelineId: string) => { + favoriteTimeline({ timelineId, timelineType: 'default' }).then(() => { + cy.stubSearchStrategyApi(overviewFixture, 'overviewNetwork'); + loginAndWaitForPage(OVERVIEW_URL); + cy.get('[data-test-subj="overview-recent-timelines"]').should( + 'contain', + timeline.title + ); + }); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_templates/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_templates/creation.spec.ts index a600b5edfd632..e2c1d7eef38c3 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_templates/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_templates/creation.spec.ts @@ -16,6 +16,7 @@ import { NOTES_TEXT_AREA, PIN_EVENT, TIMELINE_DESCRIPTION, + TIMELINE_FLYOUT_WRAPPER, TIMELINE_QUERY, TIMELINE_TITLE, } from '../../screens/timeline'; @@ -25,34 +26,38 @@ import { TIMELINES_NOTES_COUNT, TIMELINES_FAVORITE, } from '../../screens/timelines'; +import { createTimeline } from '../../tasks/api_calls/timelines'; import { cleanKibana } from '../../tasks/common'; -import { loginAndWaitForPage } from '../../tasks/login'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { openTimelineUsingToggle } from '../../tasks/security_main'; import { addDescriptionToTimeline, addFilter, addNameToTimeline, addNotesToTimeline, + clickingOnCreateTemplateFromTimelineBtn, closeTimeline, createNewTimelineTemplate, + expandEventAction, markAsFavorite, openTimelineTemplateFromSettings, populateTimeline, waitForTimelineChanges, } from '../../tasks/timeline'; -import { openTimeline } from '../../tasks/timelines'; +import { openTimeline, waitForTimelinesPanelToBeLoaded } from '../../tasks/timelines'; -import { OVERVIEW_URL } from '../../urls/navigation'; +import { TIMELINES_URL } from '../../urls/navigation'; describe('Timeline Templates', () => { beforeEach(() => { cleanKibana(); + loginAndWaitForPageWithoutDateRange(TIMELINES_URL); + cy.intercept('PATCH', '/api/timeline').as('timeline'); }); it('Creates a timeline template', async () => { - loginAndWaitForPage(OVERVIEW_URL); openTimelineUsingToggle(); createNewTimelineTemplate(); populateTimeline(); @@ -97,4 +102,22 @@ describe('Timeline Templates', () => { cy.get(NOTES).should('have.text', timeline.notes); }); }); + + it('Create template from timeline', () => { + waitForTimelinesPanelToBeLoaded(); + + createTimeline(timeline).then(() => { + expandEventAction(); + clickingOnCreateTemplateFromTimelineBtn(); + cy.wait('@timeline', { timeout: 100000 }).then(({ request }) => { + expect(request.body.timeline).to.haveOwnProperty('templateTimelineId'); + expect(request.body.timeline).to.haveOwnProperty('description', timeline.description); + expect(request.body.timeline.kqlQuery.filterQuery.kuery).to.haveOwnProperty( + 'expression', + timeline.query + ); + cy.get(TIMELINE_FLYOUT_WRAPPER).should('have.css', 'visibility', 'visible'); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts index b08bae26bf7ed..8a90b67682cb2 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts @@ -8,32 +8,37 @@ import { timeline } from '../../objects/timeline'; import { - FAVORITE_TIMELINE, LOCKED_ICON, NOTES_TEXT, PIN_EVENT, + SERVER_SIDE_EVENT_COUNT, TIMELINE_FILTER, + TIMELINE_FLYOUT_WRAPPER, TIMELINE_PANEL, + TIMELINE_TAB_CONTENT_EQL, } from '../../screens/timeline'; +import { createTimelineTemplate } from '../../tasks/api_calls/timelines'; import { cleanKibana } from '../../tasks/common'; -import { loginAndWaitForPage } from '../../tasks/login'; +import { loginAndWaitForPage, loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { openTimelineUsingToggle } from '../../tasks/security_main'; import { + addEqlToTimeline, addFilter, addNameAndDescriptionToTimeline, addNotesToTimeline, + clickingOnCreateTimelineFormTemplateBtn, closeTimeline, createNewTimeline, + expandEventAction, goToQueryTab, - markAsFavorite, pinFirstEvent, populateTimeline, - waitForTimelineChanges, } from '../../tasks/timeline'; -import { OVERVIEW_URL } from '../../urls/navigation'; +import { OVERVIEW_URL, TIMELINE_TEMPLATES_URL } from '../../urls/navigation'; +import { waitForTimelinesPanelToBeLoaded } from '../../tasks/timelines'; describe('Timelines', (): void => { before(() => { @@ -88,10 +93,44 @@ describe('Timelines', (): void => { cy.get(NOTES_TEXT).should('have.text', timeline.notes); }); - it('can be marked as favorite', () => { - markAsFavorite(); - waitForTimelineChanges(); - cy.get(FAVORITE_TIMELINE).should('have.text', 'Remove from favorites'); + it('should update timeline after adding eql', () => { + cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); + const eql = 'any where process.name == "which"'; + addEqlToTimeline(eql); + + cy.wait('@updateTimeline', { timeout: 10000 }).its('response.statusCode').should('eq', 200); + + cy.get(`${TIMELINE_TAB_CONTENT_EQL} ${SERVER_SIDE_EVENT_COUNT}`) + .invoke('text') + .then(parseInt) + .should('be.gt', 0); + }); + }); +}); + +describe('Create a timeline from a template', () => { + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(TIMELINE_TEMPLATES_URL); + waitForTimelinesPanelToBeLoaded(); + }); + + it('Should have the same query and open the timeline modal', () => { + createTimelineTemplate(timeline).then(() => { + expandEventAction(); + cy.intercept('/api/timeline').as('timeline'); + + clickingOnCreateTimelineFormTemplateBtn(); + cy.wait('@timeline', { timeout: 100000 }).then(({ request }) => { + if (request.body && request.body.timeline) { + expect(request.body.timeline).to.haveOwnProperty('description', timeline.description); + expect(request.body.timeline.kqlQuery.filterQuery.kuery).to.haveOwnProperty( + 'expression', + timeline.query + ); + cy.get(TIMELINE_FLYOUT_WRAPPER).should('have.css', 'visibility', 'visible'); + } + }); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts index c7ec17d027e80..38c6f41f1049c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts @@ -61,8 +61,10 @@ describe('timeline flyout button', () => { it('the `(+)` button popover menu owns focus', () => { cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').click({ force: true }); - cy.get(CREATE_NEW_TIMELINE).should('have.focus'); - cy.get('body').type('{esc}'); + cy.get(`${CREATE_NEW_TIMELINE}`) + .pipe(($el) => $el.trigger('focus')) + .should('have.focus'); + cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').type('{esc}'); cy.get(CREATE_NEW_TIMELINE).should('not.be.visible'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/full_screen.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/full_screen.spec.ts new file mode 100644 index 0000000000000..9cd3b22fc2bb4 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/full_screen.spec.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TIMELINE_HEADER, TIMELINE_TABS } from '../../screens/timeline'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; +import { + openTimelineUsingToggle, + enterFullScreenMode, + exitFullScreenMode, +} from '../../tasks/security_main'; +import { populateTimeline } from '../../tasks/timeline'; + +import { HOSTS_URL } from '../../urls/navigation'; + +describe('Toggle full screen', () => { + before(() => { + cleanKibana(); + loginAndWaitForPage(HOSTS_URL); + openTimelineUsingToggle(); + populateTimeline(); + }); + + it('Should hide timeline header and tab list area', () => { + enterFullScreenMode(); + + cy.get(TIMELINE_TABS).should('not.exist'); + cy.get(TIMELINE_HEADER).should('not.be.visible'); + }); + + it('Should show timeline header and tab list area', () => { + exitFullScreenMode(); + cy.get(TIMELINE_TABS).should('exist'); + cy.get(TIMELINE_HEADER).should('be.visible'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/notes_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/notes_tab.spec.ts index 2505930f72f82..24309b8fda084 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/notes_tab.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/notes_tab.spec.ts @@ -7,7 +7,13 @@ import { timelineNonValidQuery } from '../../objects/timeline'; -import { NOTES_TEXT, NOTES_TEXT_AREA } from '../../screens/timeline'; +import { + NOTES_AUTHOR, + NOTES_CODE_BLOCK, + NOTES_LINK, + NOTES_TEXT, + NOTES_TEXT_AREA, +} from '../../screens/timeline'; import { createTimeline } from '../../tasks/api_calls/timelines'; import { cleanKibana } from '../../tasks/common'; @@ -16,6 +22,7 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { addNotesToTimeline, closeTimeline, + goToNotesTab, openTimelineById, refreshTimelinesUntilTimeLinePresent, } from '../../tasks/timeline'; @@ -23,8 +30,11 @@ import { waitForTimelinesPanelToBeLoaded } from '../../tasks/timelines'; import { TIMELINES_URL } from '../../urls/navigation'; +const text = 'elastic'; +const link = 'https://www.elastic.co/'; + describe('Timeline notes tab', () => { - before(() => { + beforeEach(() => { cleanKibana(); loginAndWaitForPageWithoutDateRange(TIMELINES_URL); waitForTimelinesPanelToBeLoaded(); @@ -37,19 +47,62 @@ describe('Timeline notes tab', () => { // request responses and indeterminism since on clicks to activates URL's. .then(() => cy.wait(1000)) .then(() => openTimelineById(timelineId)) - .then(() => addNotesToTimeline(timelineNonValidQuery.notes)) + .then(() => goToNotesTab()) ); }); after(() => { closeTimeline(); }); + it('should render mockdown', () => { + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(timelineNonValidQuery.notes); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(NOTES_TEXT_AREA).should('exist'); + }); it('should contain notes', () => { - cy.get(NOTES_TEXT).should('have.text', timelineNonValidQuery.notes); + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(timelineNonValidQuery.notes); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(NOTES_TEXT).first().should('have.text', timelineNonValidQuery.notes); }); - it('should render mockdown', () => { - cy.get(NOTES_TEXT_AREA).should('exist'); + it('should be able to render font in bold', () => { + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(`**bold**`); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(`${NOTES_TEXT} strong`).last().should('have.text', `bold`); + }); + + it('should be able to render font in italics', () => { + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(`_italics_`); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(`${NOTES_TEXT} em`).last().should('have.text', `italics`); + }); + + it('should be able to render code blocks', () => { + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(`\`code\``); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(NOTES_CODE_BLOCK).should('exist'); + }); + + it('should render the right author', () => { + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(timelineNonValidQuery.notes); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(NOTES_AUTHOR).first().should('have.text', text); + }); + + it('should be able to render a link', () => { + cy.intercept('/api/note').as(`updateNote`); + cy.intercept(link).as(`link`); + addNotesToTimeline(`[${text}](${link})`); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(NOTES_LINK).last().should('have.text', `${text}(opens in a new tab or window)`); + cy.get(NOTES_LINK).last().click(); + cy.wait('@link').its('response.statusCode').should('eq', 200); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts new file mode 100644 index 0000000000000..568fb90568fb3 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + TIMELINE_EVENT, + TIMELINE_EVENTS_COUNT_NEXT_PAGE, + TIMELINE_EVENTS_COUNT_PER_PAGE, + TIMELINE_EVENTS_COUNT_PER_PAGE_BTN, + TIMELINE_EVENTS_COUNT_PER_PAGE_OPTION, + TIMELINE_EVENTS_COUNT_PREV_PAGE, +} from '../../screens/timeline'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; +import { openTimelineUsingToggle } from '../../tasks/security_main'; +import { populateTimeline } from '../../tasks/timeline'; + +import { HOSTS_URL } from '../../urls/navigation'; + +const defaultPageSize = 25; +describe('Pagination', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPage(HOSTS_URL); + openTimelineUsingToggle(); + populateTimeline(); + }); + + it(`should have ${defaultPageSize} events in the page by default`, () => { + cy.get(TIMELINE_EVENT).should('have.length', defaultPageSize); + }); + + it(`should select ${defaultPageSize} items per page by default`, () => { + cy.get(TIMELINE_EVENTS_COUNT_PER_PAGE).should('contain.text', defaultPageSize); + }); + + it('should be able to change items count per page with the dropdown', () => { + const itemsPerPage = 100; + cy.intercept('POST', '/internal/bsearch').as('refetch'); + + cy.get(TIMELINE_EVENTS_COUNT_PER_PAGE_BTN).first().click(); + cy.get(TIMELINE_EVENTS_COUNT_PER_PAGE_OPTION(itemsPerPage)).click(); + cy.wait('@refetch').its('response.statusCode').should('eq', 200); + cy.get(TIMELINE_EVENTS_COUNT_PER_PAGE).should('contain.text', itemsPerPage); + }); + + it('should be able to go to next / previous page', () => { + cy.intercept('POST', '/internal/bsearch').as('refetch'); + cy.get(TIMELINE_EVENTS_COUNT_NEXT_PAGE).first().click(); + cy.wait('@refetch').its('response.statusCode').should('eq', 200); + + cy.get(TIMELINE_EVENTS_COUNT_PREV_PAGE).first().click(); + cy.wait('@refetch').its('response.statusCode').should('eq', 200); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/query_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/query_tab.spec.ts index 672e930bc5072..f37a66ac048fb 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/query_tab.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/query_tab.spec.ts @@ -7,7 +7,13 @@ import { timeline } from '../../objects/timeline'; -import { UNLOCKED_ICON, PIN_EVENT, TIMELINE_FILTER, TIMELINE_QUERY } from '../../screens/timeline'; +import { + UNLOCKED_ICON, + PIN_EVENT, + TIMELINE_FILTER, + TIMELINE_QUERY, + NOTE_CARD_CONTENT, +} from '../../screens/timeline'; import { addNoteToTimeline } from '../../tasks/api_calls/notes'; import { createTimeline } from '../../tasks/api_calls/timelines'; @@ -18,6 +24,7 @@ import { addFilter, closeTimeline, openTimelineById, + persistNoteToFirstEvent, pinFirstEvent, refreshTimelinesUntilTimeLinePresent, } from '../../tasks/timeline'; @@ -45,6 +52,7 @@ describe('Timeline query tab', () => { ) .then(() => openTimelineById(timelineId)) .then(() => pinFirstEvent()) + .then(() => persistNoteToFirstEvent('event note')) .then(() => addFilter(timeline.filter)); }); }); @@ -58,6 +66,10 @@ describe('Timeline query tab', () => { cy.get(TIMELINE_QUERY).should('have.text', `${timeline.query}`); }); + it('should be able to add event note', () => { + cy.get(NOTE_CARD_CONTENT).should('contain', 'event note'); + }); + it('should display timeline filter', () => { cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts new file mode 100644 index 0000000000000..ed9a7db4702d0 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + TIMELINE_ROW_RENDERERS_DISABLE_ALL_BTN, + TIMELINE_ROW_RENDERERS_MODAL_CLOSE_BUTTON, + TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX, + TIMELINE_ROW_RENDERERS_SEARCHBOX, + TIMELINE_SHOW_ROW_RENDERERS_GEAR, +} from '../../screens/timeline'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; +import { openTimelineUsingToggle } from '../../tasks/security_main'; +import { populateTimeline } from '../../tasks/timeline'; + +import { HOSTS_URL } from '../../urls/navigation'; + +const RowRenderersId = [ + 'alerts', + 'auditd', + 'auditd_file', + 'library', + 'netflow', + 'plain', + 'registry', + 'suricata', + 'system', + 'system_dns', + 'system_endgame_process', + 'system_file', + 'system_fim', + 'system_security_event', + 'system_socket', + 'threat_match', + 'zeek', +]; + +describe('Row renderers', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPage(HOSTS_URL); + openTimelineUsingToggle(); + populateTimeline(); + cy.get(TIMELINE_SHOW_ROW_RENDERERS_GEAR).first().click({ force: true }); + }); + + afterEach(() => { + cy.get(TIMELINE_ROW_RENDERERS_MODAL_CLOSE_BUTTON).click({ force: true }); + }); + + it('Row renderers should be enabled by default', () => { + cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).should('exist'); + cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).should('be.checked'); + }); + + it('Selected renderer can be disabled and enabled', () => { + cy.get(TIMELINE_ROW_RENDERERS_SEARCHBOX).type('flow'); + + cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).first().uncheck(); + cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); + + cy.wait('@updateTimeline').then((interception) => { + expect(interception.request.body.timeline.excludedRowRendererIds).to.contain('netflow'); + }); + + cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).first().check(); + + cy.wait('@updateTimeline').then((interception) => { + expect(interception.request.body.timeline.excludedRowRendererIds).not.to.contain('netflow'); + }); + }); + + it('Selected renderer can be disabled with one click', () => { + cy.get(TIMELINE_ROW_RENDERERS_DISABLE_ALL_BTN).click({ force: true }); + + cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); + cy.wait('@updateTimeline').its('response.statusCode').should('eq', 200); + + cy.wait('@updateTimeline').then((interception) => { + expect(interception.request.body.timeline.excludedRowRendererIds).to.eql(RowRenderersId); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/search_or_filter.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/search_or_filter.spec.ts index 48b00f8afd4eb..9d019cf23ebb1 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/search_or_filter.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/search_or_filter.spec.ts @@ -5,14 +5,21 @@ * 2.0. */ -import { SERVER_SIDE_EVENT_COUNT } from '../../screens/timeline'; +import { + ADD_FILTER, + SERVER_SIDE_EVENT_COUNT, + TIMELINE_KQLMODE_FILTER, + TIMELINE_KQLMODE_SEARCH, + TIMELINE_SEARCH_OR_FILTER, +} from '../../screens/timeline'; import { cleanKibana } from '../../tasks/common'; -import { loginAndWaitForPage } from '../../tasks/login'; +import { loginAndWaitForPage, loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { openTimelineUsingToggle } from '../../tasks/security_main'; import { executeTimelineKQL } from '../../tasks/timeline'; +import { waitForTimelinesPanelToBeLoaded } from '../../tasks/timelines'; -import { HOSTS_URL } from '../../urls/navigation'; +import { HOSTS_URL, TIMELINES_URL } from '../../urls/navigation'; describe('timeline search or filter KQL bar', () => { beforeEach(() => { @@ -28,3 +35,37 @@ describe('timeline search or filter KQL bar', () => { cy.get(SERVER_SIDE_EVENT_COUNT).should(($count) => expect(+$count.text()).to.be.gt(0)); }); }); + +describe('Update kqlMode for timeline', () => { + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(TIMELINES_URL); + waitForTimelinesPanelToBeLoaded(); + openTimelineUsingToggle(); + }); + + beforeEach(() => { + cy.intercept('PATCH', '/api/timeline').as('update'); + cy.get(TIMELINE_SEARCH_OR_FILTER) + .pipe(($el) => $el.trigger('click')) + .should('exist'); + }); + + it('should be able to update timeline kqlMode with filter', () => { + cy.get(TIMELINE_KQLMODE_FILTER).click(); + cy.wait('@update').then(({ response }) => { + cy.wrap(response!.statusCode).should('eql', 200); + cy.wrap(response!.body.data.persistTimeline.timeline.kqlMode).should('eql', 'filter'); + cy.get(ADD_FILTER).should('exist'); + }); + }); + + it('should be able to update timeline kqlMode with search', () => { + cy.get(TIMELINE_KQLMODE_SEARCH).click(); + cy.wait('@update').then(({ response }) => { + cy.wrap(response!.statusCode).should('eql', 200); + cy.wrap(response!.body.data.persistTimeline.timeline.kqlMode).should('eql', 'search'); + cy.get(ADD_FILTER).should('not.exist'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts index 1c519b21149a8..ce6c5662ecb9e 100644 --- a/x-pack/plugins/security_solution/cypress/screens/overview.ts +++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts @@ -145,3 +145,5 @@ export const OVERVIEW_HOST_STATS = '[data-test-subj="overview-hosts-stats"]'; export const OVERVIEW_NETWORK_STATS = '[data-test-subj="overview-network-stats"]'; export const OVERVIEW_EMPTY_PAGE = '[data-test-subj="empty-page"]'; + +export const OVERVIEW_REVENT_TIMELINES = '[data-test-subj="overview-recent-timelines"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/security_header.ts b/x-pack/plugins/security_solution/cypress/screens/security_header.ts index cb8502ef96029..a3d5b714cdb3f 100644 --- a/x-pack/plugins/security_solution/cypress/screens/security_header.ts +++ b/x-pack/plugins/security_solution/cypress/screens/security_header.ts @@ -24,3 +24,5 @@ export const OVERVIEW = '[data-test-subj="navigation-overview"]'; export const REFRESH_BUTTON = '[data-test-subj="querySubmitButton"]'; export const TIMELINES = '[data-test-subj="navigation-timelines"]'; + +export const LOADING_INDICATOR = '[data-test-subj="globalLoadingIndicator"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 88e207fcea339..0a9e5b44feb1f 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -58,6 +58,10 @@ export const UNLOCKED_ICON = '[data-test-subj="timeline-date-picker-unlock-butto export const NOTES = '[data-test-subj="note-card-body"]'; +export const NOTE_CARD_CONTENT = '[data-test-subj="notes"]'; + +export const EVENT_NOTE = '[data-test-subj="timeline-notes-button-small"]'; + export const NOTE_BY_NOTE_ID = (noteId: string) => `[data-test-subj="note-preview-${noteId}"] .euiMarkdownFormat`; @@ -69,6 +73,12 @@ export const NOTES_TAB_BUTTON = '[data-test-subj="timelineTabs-notes"]'; export const NOTES_TEXT = '.euiMarkdownFormat'; +export const NOTES_CODE_BLOCK = '.euiCodeBlock__code'; + +export const NOTES_AUTHOR = '.euiCommentEvent__headerUsername'; + +export const NOTES_LINK = '[data-test-subj="markdown-link"]'; + export const NOTES_COUNT = '[data-test-subj="timeline-notes-count"]'; export const OPEN_TIMELINE_ICON = '[data-test-subj="open-timeline-button"]'; @@ -110,6 +120,8 @@ export const PINNED_TAB_EVENTS_BODY = '[data-test-subj="pinned-tab-flyout-body"] export const PINNED_TAB_EVENTS_FOOTER = '[data-test-subj="pinned-tab-flyout-footer"]'; +export const QUERY_TAB_BUTTON = '[data-test-subj="timelineTabs-query"]'; + export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]'; export const STAR_ICON = '[data-test-subj="timeline-favorite-empty-star"]'; @@ -118,6 +130,17 @@ export const TIMELINE_CHANGES_IN_PROGRESS = '[data-test-subj="timeline"] .euiPro export const TIMELINE_COLUMN_SPINNER = '[data-test-subj="timeline-loading-spinner"]'; +export const TIMELINE_COLLAPSED_ITEMS_BTN = '[data-test-subj="euiCollapsedItemActionsButton"]'; + +export const TIMELINE_CREATE_TEMPLATE_FROM_TIMELINE_BTN = + '[data-test-subj="create-template-from-timeline"]'; + +export const TIMELINE_CREATE_TIMELINE_FROM_TEMPLATE_BTN = '[data-test-subj="create-from-template"]'; + +export const TIMELINE_CORRELATION_INPUT = '[data-test-subj="eqlQueryBarTextInput"]'; + +export const TIMELINE_CORRELATION_TAB = '[data-test-subj="timelineTabs-eql"]'; + export const IS_DRAGGING_DATA_PROVIDERS = '.is-dragging'; export const TIMELINE_DATA_PROVIDERS = '[data-test-subj="dataProviders"]'; @@ -143,6 +166,19 @@ export const TIMELINE_DESCRIPTION_INPUT = '[data-test-subj="save-timeline-descri export const TIMELINE_DROPPED_DATA_PROVIDERS = '[data-test-subj="providerContainer"]'; +export const TIMELINE_EVENT = '[data-test-subj="event"]'; + +export const TIMELINE_EVENTS_COUNT_PER_PAGE = '[data-test-subj="local-events-count"]'; + +export const TIMELINE_EVENTS_COUNT_PER_PAGE_BTN = '[data-test-subj="local-events-count-button"]'; + +export const TIMELINE_EVENTS_COUNT_PER_PAGE_OPTION = (itemsPerPage: number) => + `[data-test-subj="items-per-page-option-${itemsPerPage}"]`; + +export const TIMELINE_EVENTS_COUNT_NEXT_PAGE = '[data-test-subj="pagination-button-next"]'; + +export const TIMELINE_EVENTS_COUNT_PREV_PAGE = '[data-test-subj="pagination-button-previous"]'; + export const TIMELINE_FIELDS_BUTTON = '[data-test-subj="timeline"] [data-test-subj="show-field-browser"]'; @@ -164,6 +200,8 @@ export const TIMELINE_FLYOUT_HEADER = '[data-test-subj="query-tab-flyout-header" export const TIMELINE_FLYOUT_BODY = '[data-test-subj="query-tab-flyout-body"]'; +export const TIMELINE_HEADER = '[data-test-subj="timeline-hide-show-container"]'; + export const TIMELINE_INSPECT_BUTTON = `${TIMELINE_FLYOUT} [data-test-subj="inspect-icon-button"]`; export const TIMELINE_PANEL = `[data-test-subj="timeline-flyout-header-panel"]`; @@ -172,6 +210,14 @@ export const TIMELINE_QUERY = '[data-test-subj="timelineQueryInput"]'; export const TIMELINE_SETTINGS_ICON = '[data-test-subj="settings-plus-in-circle"]'; +export const TIMELINE_SEARCH_OR_FILTER = '[data-test-subj="timeline-select-search-or-filter"]'; + +export const TIMELINE_SEARCH_OR_FILTER_CONTENT = '.searchOrFilterPopover'; + +export const TIMELINE_KQLMODE_SEARCH = '[data-test-subj="kqlModePopoverSearch"]'; + +export const TIMELINE_KQLMODE_FILTER = '[data-test-subj="kqlModePopoverFilter"]'; + export const TIMELINE_TITLE = '[data-test-subj="timeline-title"]'; export const TIMELINE_TITLE_INPUT = '[data-test-subj="save-timeline-title"]'; @@ -186,4 +232,33 @@ export const TIMELINE_EDIT_MODAL_OPEN_BUTTON = '[data-test-subj="save-timeline-b export const TIMELINE_EDIT_MODAL_SAVE_BUTTON = '[data-test-subj="save-button"]'; -export const QUERY_TAB_BUTTON = '[data-test-subj="timelineTabs-query"]'; +export const TIMELINE_EXIT_FULL_SCREEN_BUTTON = '[data-test-subj="exit-full-screen"]'; + +export const TIMELINE_FLYOUT_WRAPPER = '[data-test-subj="flyout-pane-wrapper"]'; + +export const TIMELINE_FULL_SCREEN_BUTTON = '[data-test-subj="full-screen-active"]'; + +export const TIMELINE_ROW_RENDERERS_MODAL = '[data-test-subj="row-renderers-modal"]'; + +export const TIMELINE_ROW_RENDERERS_DISABLE_ALL_BTN = `[data-test-subj="disable-all"]`; + +export const TIMELINE_ROW_RENDERERS_ENABLE_ALL_BTN = `button[data-test-subj="enable-alll"]`; + +export const TIMELINE_ROW_RENDERERS_MODAL_CLOSE_BUTTON = `${TIMELINE_ROW_RENDERERS_MODAL} .euiModal__closeIcon`; + +export const TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX = `${TIMELINE_ROW_RENDERERS_MODAL} .euiCheckbox__input`; + +export const TIMELINE_ROW_RENDERERS_SEARCHBOX = `${TIMELINE_ROW_RENDERERS_MODAL} input[type="search"]`; + +export const TIMELINE_SHOW_ROW_RENDERERS_GEAR = '[data-test-subj="show-row-renderers-gear"]'; + +export const TIMELINE_TABS = '[data-test-subj="timeline"] .euiTabs'; + +export const TIMELINE_TAB_CONTENT_EQL = '[data-test-subj="timeline-tab-content-eql"]'; + +export const TIMELINE_TAB_CONTENT_QUERY = '[data-test-subj="timeline-tab-content-query"]'; + +export const TIMELINE_TAB_CONTENT_PINNED = '[data-test-subj="timeline-tab-content-pinned"]'; + +export const TIMELINE_TAB_CONTENT_GRAPHS_NOTES = + '[data-test-subj="timeline-tab-content-graph-notes"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts index 18359574633e9..8274d19f77a25 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts @@ -119,3 +119,26 @@ export const loadPrepackagedTimelineTemplates = () => url: 'api/timeline/_prepackaged', headers: { 'kbn-xsrf': 'cypress-creds' }, }); + +export const favoriteTimeline = ({ + timelineId, + timelineType, + templateTimelineId, + templateTimelineVersion, +}: { + timelineId: string; + timelineType: string; + templateTimelineId?: string; + templateTimelineVersion?: number; +}) => + cy.request({ + method: 'PATCH', + url: 'api/timeline/_favorite', + body: { + timelineId, + timelineType, + templateTimelineId: templateTimelineId || null, + templateTimelineVersion: templateTimelineVersion || null, + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts index 189ef1e46e4bc..01651b7b943d0 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts @@ -11,6 +11,7 @@ import { TIMELINE_TOGGLE_BUTTON, TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON, } from '../screens/security_main'; +import { TIMELINE_EXIT_FULL_SCREEN_BUTTON, TIMELINE_FULL_SCREEN_BUTTON } from '../screens/timeline'; export const openTimelineUsingToggle = () => { cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).click(); @@ -30,3 +31,11 @@ export const openTimelineIfClosed = () => openTimelineUsingToggle(); } }); + +export const enterFullScreenMode = () => { + cy.get(TIMELINE_FULL_SCREEN_BUTTON).first().click({ force: true }); +}; + +export const exitFullScreenMode = () => { + cy.get(TIMELINE_EXIT_FULL_SCREEN_BUTTON).first().click({ force: true }); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 587e4ec45b8c7..af7a7bb5d4c71 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -8,6 +8,7 @@ import { Timeline, TimelineFilter } from '../objects/timeline'; import { ALL_CASES_CREATE_NEW_CASE_TABLE_BTN } from '../screens/all_cases'; +import { LOADING_INDICATOR } from '../screens/security_header'; import { ADD_FILTER, @@ -56,6 +57,13 @@ import { TIMELINE_DATA_PROVIDER_OPERATOR, TIMELINE_DATA_PROVIDER_VALUE, SAVE_DATA_PROVIDER_BTN, + EVENT_NOTE, + TIMELINE_CORRELATION_INPUT, + TIMELINE_CORRELATION_TAB, + TIMELINE_CREATE_TIMELINE_FROM_TEMPLATE_BTN, + TIMELINE_CREATE_TEMPLATE_FROM_TIMELINE_BTN, + TIMELINE_COLLAPSED_ITEMS_BTN, + TIMELINE_TAB_CONTENT_EQL, } from '../screens/timeline'; import { REFRESH_BUTTON, TIMELINE } from '../screens/timelines'; @@ -99,6 +107,16 @@ export const goToNotesTab = (): Cypress.Chainable> => { return cy.root().find(NOTES_TAB_BUTTON); }; +export const goToCorrelationTab = () => { + cy.root() + .pipe(($el) => { + $el.find(TIMELINE_CORRELATION_TAB).trigger('click'); + return $el.find(`${TIMELINE_TAB_CONTENT_EQL} ${TIMELINE_CORRELATION_INPUT}`); + }) + .should('be.visible'); + return cy.root().find(TIMELINE_CORRELATION_TAB); +}; + export const getNotePreviewByNoteId = (noteId: string) => { return cy.get(`[data-test-subj="note-preview-${noteId}"]`); }; @@ -127,6 +145,12 @@ export const addNotesToTimeline = (notes: string) => { goToNotesTab(); }; +export const addEqlToTimeline = (eql: string) => { + goToCorrelationTab().then(() => { + cy.get(TIMELINE_CORRELATION_INPUT).type(eql); + }); +}; + export const addFilter = (filter: TimelineFilter): Cypress.Chainable> => { cy.get(ADD_FILTER).click(); cy.get(TIMELINE_FILTER_FIELD).type(`${filter.field}{downarrow}{enter}`); @@ -140,7 +164,8 @@ export const addFilter = (filter: TimelineFilter): Cypress.Chainable> => { cy.get(TIMELINE_ADD_FIELD_BUTTON).click(); - cy.wait(300); + cy.get(TIMELINE_DATA_PROVIDER_VALUE).should('have.focus'); // make sure the focus is ready before start typing + cy.get(TIMELINE_DATA_PROVIDER_FIELD).type(`${filter.field}{downarrow}{enter}`); cy.get(TIMELINE_DATA_PROVIDER_OPERATOR).type(filter.operator); cy.get(COMBO_BOX).contains(filter.operator).click(); @@ -209,8 +234,10 @@ export const expandFirstTimelineEventDetails = () => { cy.get(TOGGLE_TIMELINE_EXPAND_EVENT).first().click({ force: true }); }; -export const markAsFavorite = (): Cypress.Chainable> => { - return cy.get(STAR_ICON).click(); +export const markAsFavorite = () => { + const click = ($el: Cypress.ObjectLike) => cy.wrap($el).click(); + cy.get(STAR_ICON).should('be.visible').pipe(click); + cy.get(LOADING_INDICATOR).should('not.exist'); }; export const openTimelineFieldsBrowser = () => { @@ -249,6 +276,15 @@ export const pinFirstEvent = (): Cypress.Chainable> => { return cy.get(PIN_EVENT).first().click({ force: true }); }; +export const persistNoteToFirstEvent = (notes: string) => { + cy.get(EVENT_NOTE).first().click({ force: true }); + cy.get(NOTES_TEXT_AREA).type(notes); + cy.root().pipe(($el) => { + $el.find(ADD_NOTE_BUTTON).trigger('click'); + return $el.find(NOTES_TAB_BUTTON).find('.euiBadge'); + }); +}; + export const populateTimeline = () => { executeTimelineKQL(hostExistsQuery); cy.get(SERVER_SIDE_EVENT_COUNT).should('not.have.text', '0'); @@ -325,3 +361,15 @@ export const refreshTimelinesUntilTimeLinePresent = ( }) .should('be.visible'); }; + +export const clickingOnCreateTimelineFormTemplateBtn = () => { + cy.get(TIMELINE_CREATE_TIMELINE_FROM_TEMPLATE_BTN).click({ force: true }); +}; + +export const clickingOnCreateTemplateFromTimelineBtn = () => { + cy.get(TIMELINE_CREATE_TEMPLATE_FROM_TIMELINE_BTN).click({ force: true }); +}; + +export const expandEventAction = () => { + cy.get(TIMELINE_COLLAPSED_ITEMS_BTN).first().click(); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx index 44405748b6373..4ceacc40942e2 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx @@ -44,8 +44,8 @@ describe('when using the EndpointHostIsolationStatus component', () => { }); it.each([ - ['Isolating pending', { pendingIsolate: 2 }], - ['Unisolating pending', { pendingUnIsolate: 2 }], + ['Isolating', { pendingIsolate: 2 }], + ['Releasing', { pendingUnIsolate: 2 }], ['4 actions pending', { isIsolated: true, pendingUnIsolate: 2, pendingIsolate: 2 }], ])('should show %s}', (expectedLabel, componentProps) => { const { getByTestId } = render(componentProps); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx index 0fe3a8e4337cb..7ae7cae89f19e 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx @@ -74,7 +74,7 @@ export const EndpointHostIsolationStatus = memo {pendingUnIsolate} @@ -101,12 +101,12 @@ export const EndpointHostIsolationStatus = memo ) : ( )} diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts index 790c951f61ccd..66d9bf3a7c71b 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts @@ -26,13 +26,13 @@ export const COMMENT_PLACEHOLDER = i18n.translate( export const GET_ISOLATION_SUCCESS_MESSAGE = (hostName: string) => i18n.translate('xpack.securitySolution.endpoint.hostIsolation.isolation.successfulMessage', { - defaultMessage: 'Host Isolation on {hostName} successfully submitted', + defaultMessage: 'Isolation on host {hostName} successfully submitted', values: { hostName }, }); export const GET_UNISOLATION_SUCCESS_MESSAGE = (hostName: string) => i18n.translate('xpack.securitySolution.endpoint.hostIsolation.unisolate.successfulMessage', { - defaultMessage: 'Host Unisolation on {hostName} successfully submitted', + defaultMessage: 'Release on host {hostName} successfully submitted', values: { hostName }, }); @@ -41,7 +41,7 @@ export const ISOLATE = i18n.translate('xpack.securitySolution.endpoint.hostisola }); export const UNISOLATE = i18n.translate('xpack.securitySolution.endpoint.hostisolation.unisolate', { - defaultMessage: 'unisolate', + defaultMessage: 'release', }); export const NOT_ISOLATED = i18n.translate( diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts index 98b74817cabb6..58667c26ce2e6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts @@ -17,7 +17,7 @@ export const ISOLATE_HOST = i18n.translate( export const UNISOLATE_HOST = i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.unisolateHost', { - defaultMessage: 'Unisolate host', + defaultMessage: 'Release host', } ); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.test.ts b/x-pack/plugins/security_solution/public/management/common/utils.test.ts index 59455ccd6bb04..30354c141f833 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.test.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.test.ts @@ -15,17 +15,17 @@ describe('utils', () => { }); it('should parse simple query with term', () => { expect(parseQueryFilterToKQL('simpleQuery', searchableFields)).toBe( - 'exception-list-agnostic.attributes.name:(*simpleQuery*) OR exception-list-agnostic.attributes.description:(*simpleQuery*) OR exception-list-agnostic.attributes.entries.value:(*simpleQuery*) OR exception-list-agnostic.attributes.entries.entries.value:(*simpleQuery*)' + '(exception-list-agnostic.attributes.name:(*simpleQuery*) OR exception-list-agnostic.attributes.description:(*simpleQuery*) OR exception-list-agnostic.attributes.entries.value:(*simpleQuery*) OR exception-list-agnostic.attributes.entries.entries.value:(*simpleQuery*))' ); }); it('should parse complex query with term', () => { expect(parseQueryFilterToKQL('complex query', searchableFields)).toBe( - 'exception-list-agnostic.attributes.name:(*complex*query*) OR exception-list-agnostic.attributes.description:(*complex*query*) OR exception-list-agnostic.attributes.entries.value:(*complex*query*) OR exception-list-agnostic.attributes.entries.entries.value:(*complex*query*)' + '(exception-list-agnostic.attributes.name:(*complex*query*) OR exception-list-agnostic.attributes.description:(*complex*query*) OR exception-list-agnostic.attributes.entries.value:(*complex*query*) OR exception-list-agnostic.attributes.entries.entries.value:(*complex*query*))' ); }); it('should parse complex query with colon and backslash chars term', () => { expect(parseQueryFilterToKQL('C:\\tmpes', searchableFields)).toBe( - 'exception-list-agnostic.attributes.name:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.description:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.entries.value:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.entries.entries.value:(*C\\:\\\\tmpes*)' + '(exception-list-agnostic.attributes.name:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.description:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.entries.value:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.entries.entries.value:(*C\\:\\\\tmpes*))' ); }); it('should parse complex query with special chars term', () => { @@ -35,7 +35,7 @@ describe('utils', () => { searchableFields ) ).toBe( - "exception-list-agnostic.attributes.name:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.description:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.entries.value:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.entries.entries.value:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*)" + "(exception-list-agnostic.attributes.name:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.description:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.entries.value:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.entries.entries.value:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*))" ); }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.ts b/x-pack/plugins/security_solution/public/management/common/utils.ts index c8cf761ccaf86..616e395c8ad47 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.ts @@ -17,5 +17,5 @@ export const parseQueryFilterToKQL = (filter: string, fields: Readonly ) .join(' OR '); - return kuery; + return `(${kuery})`; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.test.tsx index 9010bb5785c1d..a860e3c45deee 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.test.tsx @@ -82,9 +82,7 @@ describe('When using the EndpointAgentStatus component', () => { }); it('should show host pending action', () => { - expect(renderResult.getByTestId('rowIsolationStatus').textContent).toEqual( - 'Isolating pending' - ); + expect(renderResult.getByTestId('rowIsolationStatus').textContent).toEqual('Isolating'); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx index 7c38c935a0b9f..408e1794ef680 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx @@ -76,7 +76,7 @@ export const useEndpointActionItems = ( children: ( ), }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index d1dab3dd07a7e..9316d2539d133 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -272,7 +272,7 @@ export const EndpointList = () => { }, { field: 'host_status', - width: '9%', + width: '14%', name: i18n.translate('xpack.securitySolution.endpoint.list.hostStatus', { defaultMessage: 'Agent Status', }), @@ -356,7 +356,7 @@ export const EndpointList = () => { }, { field: 'metadata.host.os.name', - width: '10%', + width: '9%', name: i18n.translate('xpack.securitySolution.endpoint.list.os', { defaultMessage: 'Operating System', }), diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts index 1a7889f22db16..18a5bd1e5130a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts @@ -26,7 +26,7 @@ export const ACTIVITY_LOG = { unisolatedAction: i18n.translate( 'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.unisolated', { - defaultMessage: 'unisolated host', + defaultMessage: 'released host', } ), }, @@ -46,13 +46,13 @@ export const ACTIVITY_LOG = { unisolationSuccessful: i18n.translate( 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.unisolationSuccessful', { - defaultMessage: 'host unisolation successful', + defaultMessage: 'host release successful', } ), unisolationFailed: i18n.translate( 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.unisolationFailed', { - defaultMessage: 'host unisolation failed', + defaultMessage: 'host release failed', } ), }, diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx index ace78cec1a52f..ee12c12536af5 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx @@ -45,7 +45,11 @@ const RecentTimelinesItem = React.memo( const render = useCallback( (showHoverContent) => ( - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx index 2602ca3f3cc7c..ec46985450d89 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx @@ -124,7 +124,7 @@ const FlyoutComponent: React.FC = ({ timelineId, onAppLeave }) => { <> - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx index 4dcc799d79111..04237bfa43dc6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx @@ -115,7 +115,7 @@ const StatefulRowRenderersBrowserComponent: React.FC {show && ( - + = ({ {i18n.TIMELINE_TEMPLATE} )} - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx index d087b24239a66..9479c3209ad85 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx @@ -65,6 +65,7 @@ export const options = [ ), + 'data-test-subj': 'kqlModePopoverFilter', }, { value: modes.search.mode, @@ -84,6 +85,7 @@ export const options = [ ), + 'data-test-subj': 'kqlModePopoverSearch', }, ]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index 76a2ad0960322..adaa5f98c88c4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -146,14 +146,20 @@ const ActiveTimelineTab = memo( */ return ( <> - + - + ( /> {timelineType === TimelineType.default && ( - + ( /> )} - + {isGraphOrNotesTabs && getTab(activeTimelineTab)} diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index f5d3b30bf15fa..e27a09efd9710 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -140,8 +140,6 @@ export async function getEndpointEventFiltersList( policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' })`; - await eClient.createEndpointEventFiltersList(); - return getFilteredEndpointExceptionList( eClient, schemaVersion, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts index f21259980d464..b64390f4e382f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts @@ -128,7 +128,7 @@ export class StatsQuery { index: this.indexPatterns, }); - // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response + // @ts-expect-error declare aggegations type explicitly return response.body.aggregations?.ids?.buckets.reduce( (cummulative: Record, bucket: CategoriesAgg) => ({ ...cummulative, diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 27108a03f3403..f2d1d3660d78e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -380,7 +380,6 @@ export class ManifestManager { for (const result of results) { await iterateArtifactsBuildResult(result, async (artifact, policyId) => { const artifactToAdd = baselineManifest.getArtifact(getArtifactId(artifact)) || artifact; - artifactToAdd.compressionAlgorithm = 'none'; if (!internalArtifactCompleteSchema.is(artifactToAdd)) { throw new Error(`Incomplete artifact detected: ${getArtifactId(artifactToAdd)}`); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signal_versions_by_index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signal_versions_by_index.ts index 784164e430ff0..decde16d77a38 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signal_versions_by_index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signal_versions_by_index.ts @@ -72,9 +72,8 @@ export const getSignalVersionsByIndex = async ({ }, }); - // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response - const body = response.body as SignalVersionsAggResponse; - const indexBuckets = body.aggregations.signals_indices.buckets; + const aggs = response.body.aggregations as SignalVersionsAggResponse['aggregations']; + const indexBuckets = aggs.signals_indices.buckets; return index.reduce((agg, _index) => { const bucket = indexBuckets.find((ib) => ib.key === _index); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signals_indices_in_range.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signals_indices_in_range.ts index 3c9132fc81279..af236b10d0795 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signals_indices_in_range.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signals_indices_in_range.ts @@ -72,7 +72,6 @@ export const getSignalsIndicesInRange = async ({ }, }); - // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response - const body = response.body as IndexesResponse; - return body.aggregations.indexes.buckets.map((bucket) => bucket.key); + const aggs = response.body.aggregations as IndexesResponse['aggregations']; + return aggs.indexes.buckets.map((bucket) => bucket.key); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts index 08fa2f14a0fd5..f56ed3a5e9eb4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { get } from 'lodash/fp'; import set from 'set-value'; import { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/index.ts index c58e450806849..5866695ab1641 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/index.ts @@ -24,7 +24,7 @@ export const hostOverview: SecuritySolutionFactory = { options: HostOverviewRequestOptions, response: IEsSearchResponse ): Promise => { - // @ts-expect-error @elastic/elasticsearch no way to declare type for aggregations + // @ts-expect-error specify aggregations type explicitly const aggregations: OverviewHostHit = get('aggregations', response.rawResponse) || {}; const inspect = { dsl: [inspectStringifyObject(buildOverviewHostQuery(options))], diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/index.ts index 1f85a119f3c8e..069125c6700eb 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/index.ts @@ -24,7 +24,7 @@ export const networkOverview: SecuritySolutionFactory = options: NetworkOverviewRequestOptions, response: IEsSearchResponse ): Promise => { - // @ts-expect-error @elastic/elasticsearch no way to declare type for aggregations + // @ts-expect-error specify aggregations type explicitly const aggregations: OverviewNetworkHit = get('aggregations', response.rawResponse) || {}; const inspect = { dsl: [inspectStringifyObject(buildOverviewNetworkQuery(options))], diff --git a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts index 8ce33cb8cd05b..af9c08f76f6f7 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -73,12 +73,10 @@ export function registerSnapshotsRoutes({ ignore_unavailable: true, // Allow request to succeed even if some snapshots are unavailable. }); - const { responses: fetchedResponses } = response.body; + const { responses: fetchedResponses = [] } = response.body; // Decorate each snapshot with the repository with which it's associated. - // @ts-expect-error @elastic/elasticsearch related to above incorrect type from client - fetchedResponses.forEach(({ snapshots: fetchedSnapshots }) => { - // @ts-expect-error @elastic/elasticsearch related to above incorrect type from client + fetchedResponses.forEach(({ snapshots: fetchedSnapshots = [] }) => { fetchedSnapshots.forEach((snapshot) => { snapshots.push( deserializeSnapshotDetails( diff --git a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts index e88144f2b4a35..3fe003ebc6591 100644 --- a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts @@ -788,11 +788,11 @@ describe('padBuckets', () => { padBuckets(10, 3000, { key: '2020-10-02T19:47:28.128Z-2020-10-02T19:48:28.128Z', from: 1601668048128, - // @ts-expect-error @elastic/elasticsearch doesn't decalre from_as_string property from_as_string: '2020-10-02T19:47:28.128Z', to: 1601668108128, to_as_string: '2020-10-02T19:48:28.128Z', doc_count: 0, + // @ts-expect-error result type doesn't define histogram histogram: { buckets: [], }, @@ -805,11 +805,11 @@ describe('padBuckets', () => { padBuckets(10, 3000, { key: '2020-10-02T19:47:28.128Z-2020-10-02T19:48:28.128Z', from: 1601668046000, - // @ts-expect-error @elastic/elasticsearch doesn't decalre from_as_string property from_as_string: '2020-10-02T19:47:26.000Z', to: 1601668076000, to_as_string: '2020-10-02T19:47:56.000Z', doc_count: 3, + // @ts-expect-error result type doesn't define histogram histogram: { buckets: [ { @@ -883,11 +883,11 @@ describe('padBuckets', () => { padBuckets(10, 3000, { key: '2020-10-02T20:39:45.793Z-2020-10-02T20:40:14.793Z', from: 1601671183000, - // @ts-expect-error @elastic/elasticsearch doesn't decalre from_as_string property from_as_string: '2020-10-02T20:39:43.000Z', to: 1601671213000, to_as_string: '2020-10-02T20:40:13.000Z', doc_count: 2, + // @ts-expect-error result type doesn't define histogram histogram: { buckets: [ { @@ -913,11 +913,11 @@ describe('padBuckets', () => { padBuckets(20, 3000, { key: '2020-10-02T20:39:45.793Z-2020-10-02T20:40:14.793Z', from: 1601671185793, - // @ts-expect-error @elastic/elasticsearch doesn't decalre from_as_string property from_as_string: '2020-10-02T20:39:45.793Z', to: 1601671245793, to_as_string: '2020-10-02T20:40:45.793Z', doc_count: 2, + // @ts-expect-error result type doesn't define histogram histogram: { buckets: [ { @@ -943,11 +943,11 @@ describe('padBuckets', () => { padBuckets(20, 3000, { key: '2021-02-02T10:08:32.161Z-2021-02-02T10:09:32.161Z', from: 1612260512161, - // @ts-expect-error @elastic/elasticsearch doesn't decalre from_as_string property from_as_string: '2021-02-02T10:08:32.161Z', to: 1612260572161, to_as_string: '2021-02-02T10:09:32.161Z', doc_count: 2, + // @ts-expect-error result type doesn't define histogram histogram: { buckets: [ { diff --git a/x-pack/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts index ce01660134683..0a8335ebe98f3 100644 --- a/x-pack/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -319,9 +319,9 @@ export class TaskStore { return { docs: tasks - // @ts-expect-error @elastic/elasticsearch `Hid._id` expected to be `string` + // @ts-expect-error @elastic/elasticsearch _source is optional .filter((doc) => this.serializer.isRawSavedObject(doc)) - // @ts-expect-error @elastic/elasticsearch `Hid._id` expected to be `string` + // @ts-expect-error @elastic/elasticsearch _source is optional .map((doc) => this.serializer.rawToSavedObject(doc)) .map((doc) => omit(doc, 'namespace') as SavedObject) .map(savedObjectToConcreteTaskInstance), @@ -379,10 +379,8 @@ export class TaskStore { ); return { - // @ts-expect-error @elastic/elasticsearch declares UpdateByQueryResponse.total as optional - total, - // @ts-expect-error @elastic/elasticsearch declares UpdateByQueryResponse.total as optional - updated, + total: total || 0, + updated: updated || 0, version_conflicts: conflictsCorrectedForContinuation, }; } catch (e) { diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index ea2bb28776ac2..aa30a60b3421c 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -208,7 +208,7 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { await ctx.core.elasticsearch.client.asCurrentUser.transform .putTransform({ - // @ts-expect-error @elastic/elasticsearch max_page_search_size is required in TransformPivot + // @ts-expect-error @elastic/elasticsearch group_by is expected to be optional in TransformPivot body: req.body, transform_id: transformId, }) diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9ca242de9b734..d1d8454faa00f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5490,13 +5490,9 @@ "xpack.apm.header.badge.readOnly.text": "読み取り専用", "xpack.apm.header.badge.readOnly.tooltip": "を保存できませんでした", "xpack.apm.helpMenu.upgradeAssistantLink": "アップグレードアシスタント", - "xpack.apm.home.alertsMenu.alerts": "アラート", "xpack.apm.home.alertsMenu.createAnomalyAlert": "異常アラートを作成", - "xpack.apm.home.alertsMenu.createThresholdAlert": "しきい値アラートを作成", "xpack.apm.home.alertsMenu.errorCount": "エラー数", - "xpack.apm.home.alertsMenu.transactionDuration": "レイテンシ", "xpack.apm.home.alertsMenu.transactionErrorRate": "トランザクションエラー率", - "xpack.apm.home.alertsMenu.viewActiveAlerts": "アクティブアラートを表示", "xpack.apm.home.serviceMapTabLabel": "サービスマップ", "xpack.apm.instancesLatencyDistributionChartLegend": "インスタンス", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "前の期間", @@ -9064,7 +9060,6 @@ "xpack.fleet.epm.categoryLabel": "カテゴリー", "xpack.fleet.epm.detailsTitle": "詳細", "xpack.fleet.epm.featuresLabel": "機能", - "xpack.fleet.epm.illustrationAltText": "統合の例", "xpack.fleet.epm.licenseLabel": "ライセンス", "xpack.fleet.epm.loadingIntegrationErrorTitle": "統合詳細の読み込みエラー", "xpack.fleet.epm.packageDetails.integrationList.agentCount": "エージェント", @@ -9079,7 +9074,6 @@ "xpack.fleet.epm.packageDetailsNav.packagePoliciesLinkText": "ポリシー", "xpack.fleet.epm.packageDetailsNav.settingsLinkText": "設定", "xpack.fleet.epm.pageSubtitle": "一般的なアプリやサービスの統合を参照する", - "xpack.fleet.epm.pageTitle": "統合", "xpack.fleet.epm.releaseBadge.betaDescription": "この統合は本番環境用ではありません。", "xpack.fleet.epm.releaseBadge.betaLabel": "ベータ", "xpack.fleet.epm.releaseBadge.experimentalDescription": "この統合は、急に変更されたり、将来のリリースで削除されたりする可能性があります。", @@ -10878,20 +10872,10 @@ "xpack.indexLifecycleMgmt.timeline.title": "ポリシー概要", "xpack.indexLifecycleMgmt.timeline.warmPhaseSectionTitle": "ウォームフェーズ", "xpack.indexLifecycleMgmt.warmPhase.dataTier.description": "頻度が低い読み取り専用アクセス用に最適化されたノードにデータを移動します。", - "xpack.infra.alerting.alertDropdownTitle": "アラート", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "なし (グループなし) ", "xpack.infra.alerting.alertFlyout.groupByLabel": "グループ分けの条件", - "xpack.infra.alerting.alertsButton": "アラート", - "xpack.infra.alerting.createInventoryAlertButton": "インベントリアラートの作成", - "xpack.infra.alerting.createThresholdAlertButton": "しきい値アラートを作成", "xpack.infra.alerting.infrastructureDropdownMenu": "インフラストラクチャー", - "xpack.infra.alerting.infrastructureDropdownTitle": "インフラストラクチャーアラート", - "xpack.infra.alerting.logs.alertsButton": "アラート", - "xpack.infra.alerting.logs.createAlertButton": "アラートの作成", - "xpack.infra.alerting.logs.manageAlerts": "アラートを管理", - "xpack.infra.alerting.manageAlerts": "アラートを管理", "xpack.infra.alerting.metricsDropdownMenu": "メトリック", - "xpack.infra.alerting.metricsDropdownTitle": "メトリックアラート", "xpack.infra.alerts.charts.errorMessage": "問題が発生しました", "xpack.infra.alerts.charts.loadingMessage": "読み込み中", "xpack.infra.alerts.charts.noDataMessage": "グラフデータがありません", @@ -12971,11 +12955,7 @@ "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseButtonLabel": "ライセンスを更新", "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseTitle": "ライセンスの更新", "xpack.licenseMgmt.licenseDashboard.addLicense.useAvailableLicenseDescription": "すでに新しいライセンスがある場合は、今すぐアップロードしてください。", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusDescription": "ライセンスは{expiryDate}に期限切れになります", "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText": "アクティブ", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusTitle": "ご使用の{typeTitleCase}ライセンスは{status}です", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusDescription": "ご使用のライセンスは{expiryDate}に期限切れになりました", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusTitle": "ご使用の{typeTitleCase}ライセンスは期限切れです", "xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText": "非アクティブ", "xpack.licenseMgmt.licenseDashboard.licenseStatus.permanentActiveLicenseStatusDescription": "ご使用のライセンスには有効期限がありません。", "xpack.licenseMgmt.licenseDashboard.requestTrialExtension.extendTrialButtonLabel": "トライアルを延長", @@ -15908,13 +15888,8 @@ "xpack.monitoring.alerts.nodesChanged.ui.removedFiringMessage": "Elasticsearchノード「{removed}」がこのクラスターから削除されました。", "xpack.monitoring.alerts.nodesChanged.ui.resolvedMessage": "このクラスターのElasticsearchノードは変更されていません。", "xpack.monitoring.alerts.nodesChanged.ui.restartedFiringMessage": "このクラスターでElasticsearchノード「{restarted}」が再起動しました。", - "xpack.monitoring.alerts.panel.disableAlert.errorTitle": "アラートを無効にできません", "xpack.monitoring.alerts.panel.disableTitle": "無効にする", - "xpack.monitoring.alerts.panel.editAlert": "アラートを編集", - "xpack.monitoring.alerts.panel.enableAlert.errorTitle": "アラートを有効にできません", - "xpack.monitoring.alerts.panel.muteAlert.errorTitle": "アラートをミュートできません", "xpack.monitoring.alerts.panel.muteTitle": "ミュート", - "xpack.monitoring.alerts.panel.ummuteAlert.errorTitle": "アラートをミュート解除できません", "xpack.monitoring.alerts.rejection.paramDetails.duration.label": "最後の", "xpack.monitoring.alerts.rejection.paramDetails.threshold.label": "{type} 拒否カウントが超過するときに通知", "xpack.monitoring.alerts.searchThreadPoolRejections.description": "検索スレッドプールの拒否数がしきい値を超過するときにアラートを発行します。", @@ -17241,7 +17216,6 @@ "xpack.observability.alertsTable.viewInAppButtonLabel": "アプリで表示", "xpack.observability.alertsTitle": "アラート", "xpack.observability.emptySection.apps.alert.description": "503 エラーが累積していますか?サービスは応答していますか?CPUとRAMの使用量が跳ね上がっていますか?このような警告を、事後にではなく、発生と同時に把握しましょう。", - "xpack.observability.emptySection.apps.alert.link": "アラートの作成", "xpack.observability.emptySection.apps.alert.title": "アラートが見つかりません。", "xpack.observability.emptySection.apps.apm.description": "分散アーキテクチャ全体でトランザクションを追跡し、サービスの通信をマップ化して、簡単にパフォーマンスボトルネックを特定できます。", "xpack.observability.emptySection.apps.apm.link": "エージェントのインストール", @@ -23532,8 +23506,6 @@ "xpack.uptime.alerts.tls.validAfterExpiringString": "{relativeDate}日以内、{date}に期限切れになります。", "xpack.uptime.alerts.tls.validBeforeExpiredString": "{relativeDate}日前、{date}以降有効です。", "xpack.uptime.alerts.tls.validBeforeExpiringString": "今から{relativeDate}日間、{date}まで無効です。", - "xpack.uptime.alerts.toggleAlertFlyoutButtonText": "アラート", - "xpack.uptime.alertsPopover.toggleButton.ariaLabel": "アラートコンテキストメニューを開く", "xpack.uptime.apmIntegrationAction.description": "このモニターの検索 APM", "xpack.uptime.apmIntegrationAction.text": "APMデータを表示", "xpack.uptime.availabilityLabelText": "{value} %", @@ -23752,15 +23724,11 @@ "xpack.uptime.monitorDetails.title.pingType.tcp": "TCP ping", "xpack.uptime.monitorList.anomalyColumn.label": "レスポンス異常スコア", "xpack.uptime.monitorList.defineConnector.description": "アラートを有効にするには、デフォルトのアラートアクションコネクターを定義してください。", - "xpack.uptime.monitorList.disableDownAlert": "ステータスアラートを無効にする", "xpack.uptime.monitorList.downLineSeries.downLabel": "ダウン", "xpack.uptime.monitorList.drawer.missingLocation": "一部の Heartbeat インスタンスには位置情報が定義されていません。Heartbeat 構成への{link}。", "xpack.uptime.monitorList.drawer.mostRecentRun": "直近のテスト実行", "xpack.uptime.monitorList.drawer.statusRowLocationList": "前回の確認時に\"{status}\"ステータスだった場所のリスト。", "xpack.uptime.monitorList.drawer.url": "Url", - "xpack.uptime.monitorList.enabledAlerts.noAlert": "このモニターではアラートが有効ではありません。", - "xpack.uptime.monitorList.enabledAlerts.title": "有効なアラート", - "xpack.uptime.monitorList.enableDownAlert": "ステータスアラートを有効にする", "xpack.uptime.monitorList.expandDrawerButton.ariaLabel": "ID {id}のモニターの行を展開", "xpack.uptime.monitorList.geoName.helpLinkAnnotation": "場所を追加", "xpack.uptime.monitorList.infraIntegrationAction.container.message": "コンテナーメトリックを表示", @@ -23834,15 +23802,7 @@ "xpack.uptime.monitorStatusBar.timestampFromNowTextAriaLabel": "最終確認からの経過時間", "xpack.uptime.monitorStatusBar.type.ariaLabel": "モニタータイプ", "xpack.uptime.monitorStatusBar.type.label": "型", - "xpack.uptime.navigateToAlertingButton.content": "アラートを管理", - "xpack.uptime.navigateToAlertingUi": "Uptime を離れてアラート管理ページに移動します", "xpack.uptime.notFountPage.homeLinkText": "ホームへ戻る", - "xpack.uptime.openAlertContextPanel.ariaLabel": "アラートコンテキストパネルを開くと、アラートタイプを選択できます", - "xpack.uptime.openAlertContextPanel.label": "アラートの作成", - "xpack.uptime.overview.alerts.disabled.failed": "アラートを無効にできません。", - "xpack.uptime.overview.alerts.disabled.success": "アラートが正常に無効にされました。", - "xpack.uptime.overview.alerts.enabled.failed": "アラートを有効にできません。", - "xpack.uptime.overview.alerts.enabled.success": "アラートが正常に有効にされました。 ", "xpack.uptime.overview.alerts.enabled.success.description": "この監視が停止しているときには、メッセージが {actionConnectors} に送信されます。", "xpack.uptime.overview.filterButton.label": "{title}フィルターのフィルターグループを展開", "xpack.uptime.overview.pageHeader.syntheticsCallout.announcementLink": "お知らせを読む", @@ -24010,10 +23970,6 @@ "xpack.uptime.synthetics.waterfallChart.labels.timings.ssl": "TLS", "xpack.uptime.synthetics.waterfallChart.labels.timings.wait": "待機中 (TTFB) ", "xpack.uptime.title": "アップタイム", - "xpack.uptime.toggleAlertButton.content": "ステータスアラートを監視", - "xpack.uptime.toggleAlertFlyout.ariaLabel": "アラートの追加ポップアップを開く", - "xpack.uptime.toggleTlsAlertButton.ariaLabel": "TLSアラートの追加ポップアップを開く", - "xpack.uptime.toggleTlsAlertButton.content": "TLSアラート", "xpack.uptime.uptimeFeatureCatalogueTitle": "アップタイム", "xpack.urlDrilldown.click.event.key.documentation": "クリックしたデータポイントの後ろのフィールド名。", "xpack.urlDrilldown.click.event.key.title": "クリックしたフィールドの名前。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 12ebcc6ef4b9d..1e1044485e30a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5520,13 +5520,9 @@ "xpack.apm.header.badge.readOnly.text": "只读", "xpack.apm.header.badge.readOnly.tooltip": "无法保存", "xpack.apm.helpMenu.upgradeAssistantLink": "升级助手", - "xpack.apm.home.alertsMenu.alerts": "告警", "xpack.apm.home.alertsMenu.createAnomalyAlert": "创建异常告警", - "xpack.apm.home.alertsMenu.createThresholdAlert": "创建阈值告警", "xpack.apm.home.alertsMenu.errorCount": "错误计数", - "xpack.apm.home.alertsMenu.transactionDuration": "延迟", "xpack.apm.home.alertsMenu.transactionErrorRate": "事务错误率", - "xpack.apm.home.alertsMenu.viewActiveAlerts": "查看活动告警", "xpack.apm.home.serviceMapTabLabel": "服务地图", "xpack.apm.instancesLatencyDistributionChartLegend": "实例", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "上一时段", @@ -9150,7 +9146,6 @@ "xpack.fleet.epm.categoryLabel": "类别", "xpack.fleet.epm.detailsTitle": "详情", "xpack.fleet.epm.featuresLabel": "功能", - "xpack.fleet.epm.illustrationAltText": "集成的图示", "xpack.fleet.epm.licenseLabel": "许可证", "xpack.fleet.epm.loadingIntegrationErrorTitle": "加载集成详情时出错", "xpack.fleet.epm.packageDetails.integrationList.agentCount": "代理", @@ -9165,7 +9160,6 @@ "xpack.fleet.epm.packageDetailsNav.packagePoliciesLinkText": "策略", "xpack.fleet.epm.packageDetailsNav.settingsLinkText": "设置", "xpack.fleet.epm.pageSubtitle": "浏览集成以了解热门应用和服务。", - "xpack.fleet.epm.pageTitle": "集成", "xpack.fleet.epm.releaseBadge.betaDescription": "在生产环境中不推荐使用此集成。", "xpack.fleet.epm.releaseBadge.betaLabel": "公测版", "xpack.fleet.epm.releaseBadge.experimentalDescription": "此集成可能有重大更改或将在未来版本中移除。", @@ -11017,20 +11011,10 @@ "xpack.indexLifecycleMgmt.timeline.title": "策略摘要", "xpack.indexLifecycleMgmt.timeline.warmPhaseSectionTitle": "温阶段", "xpack.indexLifecycleMgmt.warmPhase.dataTier.description": "将数据移到针对不太频繁的只读访问优化的节点。", - "xpack.infra.alerting.alertDropdownTitle": "告警", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "无内容 (未分组) ", "xpack.infra.alerting.alertFlyout.groupByLabel": "分组依据", - "xpack.infra.alerting.alertsButton": "告警", - "xpack.infra.alerting.createInventoryAlertButton": "创建库存告警", - "xpack.infra.alerting.createThresholdAlertButton": "创建阈值告警", "xpack.infra.alerting.infrastructureDropdownMenu": "基础设施", - "xpack.infra.alerting.infrastructureDropdownTitle": "基础架构告警", - "xpack.infra.alerting.logs.alertsButton": "告警", - "xpack.infra.alerting.logs.createAlertButton": "创建告警", - "xpack.infra.alerting.logs.manageAlerts": "管理告警", - "xpack.infra.alerting.manageAlerts": "管理告警", "xpack.infra.alerting.metricsDropdownMenu": "指标", - "xpack.infra.alerting.metricsDropdownTitle": "指标告警", "xpack.infra.alerts.charts.errorMessage": "哇哦,出问题了", "xpack.infra.alerts.charts.loadingMessage": "正在加载", "xpack.infra.alerts.charts.noDataMessage": "没有可用图表数据", @@ -13145,11 +13129,7 @@ "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseButtonLabel": "更新许可证", "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseTitle": "更新您的许可证", "xpack.licenseMgmt.licenseDashboard.addLicense.useAvailableLicenseDescription": "如果已有新的许可证,请立即上传。", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusDescription": "您的许可证将于 {expiryDate}过期", "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText": "活动", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusTitle": "您的{typeTitleCase}许可证{status}", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusDescription": "您的许可证已于 {expiryDate}过期", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusTitle": "您的{typeTitleCase}许可证已过期", "xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText": "非活动", "xpack.licenseMgmt.licenseDashboard.licenseStatus.permanentActiveLicenseStatusDescription": "您的许可证永不会过期。", "xpack.licenseMgmt.licenseDashboard.requestTrialExtension.extendTrialButtonLabel": "延期试用", @@ -16144,13 +16124,8 @@ "xpack.monitoring.alerts.nodesChanged.ui.removedFiringMessage": "Elasticsearch 节点“{removed}”已从此集群中移除。", "xpack.monitoring.alerts.nodesChanged.ui.resolvedMessage": "此集群的 Elasticsearch 节点中没有更改。", "xpack.monitoring.alerts.nodesChanged.ui.restartedFiringMessage": "此集群中 Elasticsearch 节点“{restarted}”已重新启动。", - "xpack.monitoring.alerts.panel.disableAlert.errorTitle": "无法禁用告警", "xpack.monitoring.alerts.panel.disableTitle": "禁用", - "xpack.monitoring.alerts.panel.editAlert": "编辑告警", - "xpack.monitoring.alerts.panel.enableAlert.errorTitle": "无法启用告警", - "xpack.monitoring.alerts.panel.muteAlert.errorTitle": "无法静音告警", "xpack.monitoring.alerts.panel.muteTitle": "静音", - "xpack.monitoring.alerts.panel.ummuteAlert.errorTitle": "无法取消告警静音", "xpack.monitoring.alerts.rejection.paramDetails.duration.label": "过去", "xpack.monitoring.alerts.rejection.paramDetails.threshold.label": "当 {type} 拒绝计数超过以下阈值时通知:", "xpack.monitoring.alerts.searchThreadPoolRejections.description": "当搜索线程池中的拒绝数目超过阈值时告警。", @@ -17477,7 +17452,6 @@ "xpack.observability.alertsTable.viewInAppButtonLabel": "在应用中查看", "xpack.observability.alertsTitle": "告警", "xpack.observability.emptySection.apps.alert.description": "503 错误是否越来越多?服务是否响应?CPU 和 RAM 利用率是否激增?实时查看警告,而不是事后再进行剖析。", - "xpack.observability.emptySection.apps.alert.link": "创建告警", "xpack.observability.emptySection.apps.alert.title": "未找到告警。", "xpack.observability.emptySection.apps.apm.description": "通过分布式体系结构跟踪事务并映射服务的交互以轻松发现性能瓶颈。", "xpack.observability.emptySection.apps.apm.link": "安装代理", @@ -23898,8 +23872,6 @@ "xpack.uptime.alerts.tls.validAfterExpiringString": "将在{relativeDate} 天后,即 {date}到期。", "xpack.uptime.alerts.tls.validBeforeExpiredString": "自 {relativeDate} 天前,即 {date}开始生效。", "xpack.uptime.alerts.tls.validBeforeExpiringString": "从现在到 {date}的 {relativeDate} 天里无效。", - "xpack.uptime.alerts.toggleAlertFlyoutButtonText": "告警", - "xpack.uptime.alertsPopover.toggleButton.ariaLabel": "打开告警上下文菜单", "xpack.uptime.apmIntegrationAction.description": "在 APM 中搜索此监测", "xpack.uptime.apmIntegrationAction.text": "显示 APM 数据", "xpack.uptime.availabilityLabelText": "{value} %", @@ -24118,15 +24090,11 @@ "xpack.uptime.monitorDetails.title.pingType.tcp": "TCP ping", "xpack.uptime.monitorList.anomalyColumn.label": "响应异常分数", "xpack.uptime.monitorList.defineConnector.description": "要开始启用告警,请在以下位置定义默认告警操作连接器", - "xpack.uptime.monitorList.disableDownAlert": "禁用状态告警", "xpack.uptime.monitorList.downLineSeries.downLabel": "关闭检查", "xpack.uptime.monitorList.drawer.missingLocation": "某些 Heartbeat 实例未定义位置。{link}到您的 Heartbeat 配置。", "xpack.uptime.monitorList.drawer.mostRecentRun": "最新测试运行", "xpack.uptime.monitorList.drawer.statusRowLocationList": "上次检查时状态为“{status}”的位置列表。", "xpack.uptime.monitorList.drawer.url": "URL", - "xpack.uptime.monitorList.enabledAlerts.noAlert": "没有为此监测启用告警。", - "xpack.uptime.monitorList.enabledAlerts.title": "已启用的告警", - "xpack.uptime.monitorList.enableDownAlert": "启用状态告警", "xpack.uptime.monitorList.expandDrawerButton.ariaLabel": "展开 ID {id} 的监测行", "xpack.uptime.monitorList.geoName.helpLinkAnnotation": "添加位置", "xpack.uptime.monitorList.infraIntegrationAction.container.message": "显示容器指标", @@ -24200,15 +24168,7 @@ "xpack.uptime.monitorStatusBar.timestampFromNowTextAriaLabel": "自上次检查以来经过的时间", "xpack.uptime.monitorStatusBar.type.ariaLabel": "监测类型", "xpack.uptime.monitorStatusBar.type.label": "类型", - "xpack.uptime.navigateToAlertingButton.content": "管理告警", - "xpack.uptime.navigateToAlertingUi": "离开 Uptime 并前往“Alerting 管理”页面", "xpack.uptime.notFountPage.homeLinkText": "返回主页", - "xpack.uptime.openAlertContextPanel.ariaLabel": "打开告警上下文面板,以便可以选择告警类型", - "xpack.uptime.openAlertContextPanel.label": "创建告警", - "xpack.uptime.overview.alerts.disabled.failed": "无法禁用告警!", - "xpack.uptime.overview.alerts.disabled.success": "已成功禁用告警!", - "xpack.uptime.overview.alerts.enabled.failed": "无法启用告警!", - "xpack.uptime.overview.alerts.enabled.success": "已成功启用告警 ", "xpack.uptime.overview.alerts.enabled.success.description": "此监测关闭时,将有消息发送到 {actionConnectors}。", "xpack.uptime.overview.filterButton.label": "展开筛选 {title} 的筛选组", "xpack.uptime.overview.pageHeader.syntheticsCallout.announcementLink": "阅读公告", @@ -24376,10 +24336,6 @@ "xpack.uptime.synthetics.waterfallChart.labels.timings.ssl": "TLS", "xpack.uptime.synthetics.waterfallChart.labels.timings.wait": "等待中 (TTFB)", "xpack.uptime.title": "运行时间", - "xpack.uptime.toggleAlertButton.content": "监测状态告警", - "xpack.uptime.toggleAlertFlyout.ariaLabel": "打开添加告警浮出控件", - "xpack.uptime.toggleTlsAlertButton.ariaLabel": "打开 TLS 告警浮出控件", - "xpack.uptime.toggleTlsAlertButton.content": "TLS 告警", "xpack.uptime.uptimeFeatureCatalogueTitle": "运行时间", "xpack.urlDrilldown.click.event.key.documentation": "已点击数据点背后的字段名称。", "xpack.urlDrilldown.click.event.key.title": "已点击字段的名称。", diff --git a/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts b/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts index a2ba8d43c9c60..6b2849b7b9670 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts @@ -165,7 +165,7 @@ export function getResultFromEs( delete aggregations.dateAgg; } - // @ts-expect-error @elastic/elasticsearch Aggregate does not specify buckets + // @ts-expect-error specify aggregations type explicitly const groupBuckets = aggregations.groupAgg?.buckets || []; const result: TimeSeriesResult = { results: [], diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx index 89d8f38b1e3b3..0265588c3fdeb 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx @@ -14,12 +14,12 @@ describe('ActionMenuContent', () => { it('renders alerts dropdown', async () => { const { getByLabelText, getByText } = render(); - const alertsDropdown = getByLabelText('Open alert context menu'); + const alertsDropdown = getByLabelText('Open alerts and rules context menu'); fireEvent.click(alertsDropdown); await waitFor(() => { - expect(getByText('Create alert')); - expect(getByText('Manage alerts')); + expect(getByText('Create rule')); + expect(getByText('Manage rules')); }); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx b/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx new file mode 100644 index 0000000000000..d2fe3f9b30e84 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import styled from 'styled-components'; + +import { EuiPanel } from '@elastic/eui'; +import { CodeEditor as MonacoCodeEditor } from '../../../../../../src/plugins/kibana_react/public'; + +import { MonacoEditorLangId } from './types'; + +const CodeEditorContainer = styled(EuiPanel)` + padding: 0; +`; + +interface Props { + ariaLabel: string; + id: string; + languageId: MonacoEditorLangId; + onChange: (value: string) => void; + value: string; +} + +export const CodeEditor = ({ ariaLabel, id, languageId, onChange, value }: Props) => { + return ( + +
+ +
+
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx index c257a8f71b77a..b51aa6cbf3a7c 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx @@ -36,7 +36,7 @@ export const initialValues = { [ConfigKeys.RESPONSE_STATUS_CHECK]: [], [ConfigKeys.REQUEST_BODY_CHECK]: { value: '', - type: Mode.TEXT, + type: Mode.PLAINTEXT, }, [ConfigKeys.REQUEST_HEADERS_CHECK]: {}, [ConfigKeys.REQUEST_METHOD_CHECK]: HTTPMethod.GET, diff --git a/x-pack/plugins/uptime/public/components/fleet_package/header_field.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/header_field.test.tsx index ee33083b3eae9..6d9e578fe53f5 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/header_field.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/header_field.test.tsx @@ -76,14 +76,14 @@ describe('', () => { }); it('handles content mode', async () => { - const contentMode: Mode = Mode.TEXT; + const contentMode: Mode = Mode.PLAINTEXT; render( ); await waitFor(() => { expect(onChange).toBeCalledWith({ - 'Content-Type': contentTypes[Mode.TEXT], + 'Content-Type': contentTypes[Mode.PLAINTEXT], }); }); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/header_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/header_field.tsx index 9f337d4b00704..eaf9be50e9665 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/header_field.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/header_field.tsx @@ -61,7 +61,7 @@ export const HeaderField = ({ contentMode, defaultValue, onChange }: Props) => { export const contentTypes: Record = { [Mode.JSON]: ContentType.JSON, - [Mode.TEXT]: ContentType.TEXT, + [Mode.PLAINTEXT]: ContentType.TEXT, [Mode.XML]: ContentType.XML, [Mode.FORM]: ContentType.FORM, }; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.test.tsx index 849809eae52a4..fa666ac764ac7 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.test.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import 'jest-canvas-mock'; + import React, { useState, useCallback } from 'react'; import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../lib/helper/rtl_helpers'; @@ -16,7 +18,7 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ })); describe('', () => { - const defaultMode = Mode.TEXT; + const defaultMode = Mode.PLAINTEXT; const defaultValue = 'sample value'; const WrappedComponent = () => { const [config, setConfig] = useState({ diff --git a/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.tsx index 1ef8fdd75e7f3..1fdde7c2b63fc 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.tsx @@ -5,67 +5,13 @@ * 2.0. */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { stringify, parse } from 'query-string'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { stringify, parse } from 'query-string'; - -import styled from 'styled-components'; - -import { EuiCodeEditor, EuiPanel, EuiTabbedContent } from '@elastic/eui'; - -import { Mode } from './types'; - +import { EuiTabbedContent } from '@elastic/eui'; +import { Mode, MonacoEditorLangId } from './types'; import { KeyValuePairsField, Pair } from './key_value_field'; - -import 'brace/theme/github'; -import 'brace/mode/xml'; -import 'brace/mode/json'; -import 'brace/ext/language_tools'; - -const CodeEditorContainer = styled(EuiPanel)` - padding: 0; -`; - -enum ResponseBodyType { - CODE = 'code', - FORM = 'form', -} - -const CodeEditor = ({ - ariaLabel, - id, - mode, - onChange, - value, -}: { - ariaLabel: string; - id: string; - mode: Mode; - onChange: (value: string) => void; - value: string; -}) => { - return ( - -
- -
-
- ); -}; +import { CodeEditor } from './code_editor'; interface Props { onChange: (requestBody: { type: Mode; value: string }) => void; @@ -73,6 +19,11 @@ interface Props { value: string; } +enum ResponseBodyType { + CODE = 'code', + FORM = 'form', +} + // TO DO: Look into whether or not code editor reports errors, in order to prevent form submission on an error export const RequestBodyField = ({ onChange, type, value }: Props) => { const [values, setValues] = useState>({ @@ -129,9 +80,9 @@ export const RequestBodyField = ({ onChange, type, value }: Props) => { const tabs = [ { - id: Mode.TEXT, - name: modeLabels[Mode.TEXT], - 'data-test-subj': `syntheticsRequestBodyTab__${Mode.TEXT}`, + id: Mode.PLAINTEXT, + name: modeLabels[Mode.PLAINTEXT], + 'data-test-subj': `syntheticsRequestBodyTab__${Mode.PLAINTEXT}`, content: ( { defaultMessage: 'Text code editor', } )} - id={Mode.TEXT} - mode={Mode.TEXT} + id={Mode.PLAINTEXT} + languageId={MonacoEditorLangId.PLAINTEXT} onChange={(code) => setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })) } @@ -162,7 +113,7 @@ export const RequestBodyField = ({ onChange, type, value }: Props) => { } )} id={Mode.JSON} - mode={Mode.JSON} + languageId={MonacoEditorLangId.JSON} onChange={(code) => setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })) } @@ -183,7 +134,7 @@ export const RequestBodyField = ({ onChange, type, value }: Props) => { } )} id={Mode.XML} - mode={Mode.XML} + languageId={MonacoEditorLangId.XML} onChange={(code) => setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })) } @@ -229,7 +180,7 @@ const modeLabels = { defaultMessage: 'Form', } ), - [Mode.TEXT]: i18n.translate( + [Mode.PLAINTEXT]: i18n.translate( 'xpack.uptime.createPackagePolicy.stepConfigure.requestBodyType.text', { defaultMessage: 'Text', diff --git a/x-pack/plugins/uptime/public/components/fleet_package/types.tsx b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx index 4d44b4f074e82..7a16d1352c40a 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/types.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx @@ -25,10 +25,17 @@ export enum ResponseBodyIndexPolicy { ON_ERROR = 'on_error', } +export enum MonacoEditorLangId { + JSON = 'xjson', + PLAINTEXT = 'plaintext', + XML = 'xml', + JAVASCRIPT = 'javascript', +} + export enum Mode { FORM = 'form', JSON = 'json', - TEXT = 'text', + PLAINTEXT = 'text', XML = 'xml', } @@ -192,11 +199,11 @@ export interface PolicyConfig { [DataStream.ICMP]: ICMPFields; } -export type Validation = Partial void>>; +export type Validation = Partial boolean>>; export const contentTypesToMode = { [ContentType.FORM]: Mode.FORM, [ContentType.JSON]: Mode.JSON, - [ContentType.TEXT]: Mode.TEXT, + [ContentType.TEXT]: Mode.PLAINTEXT, [ContentType.XML]: Mode.XML, }; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx index a1b745d07924e..278958bd1987b 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx @@ -67,7 +67,7 @@ export const ToggleAlertFlyoutButtonComponent: React.FC = ({ > ), @@ -114,7 +114,7 @@ export const ToggleAlertFlyoutButtonComponent: React.FC = ({ }, { id: ALERT_CONTEXT_SELECT_TYPE_PANEL_ID, - title: 'create alerts', + title: ToggleFlyoutTranslations.toggleAlertFlyoutButtonLabel, items: selectionItems, }, ]; @@ -134,7 +134,7 @@ export const ToggleAlertFlyoutButtonComponent: React.FC = ({ > } diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts b/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts index 00a00a4664cd8..7cfcdabe5562b 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts +++ b/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts @@ -283,30 +283,33 @@ export const TlsTranslations = { export const ToggleFlyoutTranslations = { toggleButtonAriaLabel: i18n.translate('xpack.uptime.alertsPopover.toggleButton.ariaLabel', { - defaultMessage: 'Open alert context menu', + defaultMessage: 'Open alerts and rules context menu', }), openAlertContextPanelAriaLabel: i18n.translate('xpack.uptime.openAlertContextPanel.ariaLabel', { - defaultMessage: 'Open the alert context panel so you can choose an alert type', + defaultMessage: 'Open the rule context panel so you can choose a rule type', }), openAlertContextPanelLabel: i18n.translate('xpack.uptime.openAlertContextPanel.label', { - defaultMessage: 'Create alert', + defaultMessage: 'Create rule', }), toggleTlsAriaLabel: i18n.translate('xpack.uptime.toggleTlsAlertButton.ariaLabel', { - defaultMessage: 'Open TLS alert flyout', + defaultMessage: 'Open TLS rule flyout', }), toggleTlsContent: i18n.translate('xpack.uptime.toggleTlsAlertButton.content', { - defaultMessage: 'TLS alert', + defaultMessage: 'TLS rule', }), toggleMonitorStatusAriaLabel: i18n.translate('xpack.uptime.toggleAlertFlyout.ariaLabel', { - defaultMessage: 'Open add alert flyout', + defaultMessage: 'Open add rule flyout', }), toggleMonitorStatusContent: i18n.translate('xpack.uptime.toggleAlertButton.content', { - defaultMessage: 'Monitor status alert', + defaultMessage: 'Monitor status rule', }), navigateToAlertingUIAriaLabel: i18n.translate('xpack.uptime.navigateToAlertingUi', { defaultMessage: 'Leave Uptime and go to Alerting Management page', }), navigateToAlertingButtonContent: i18n.translate('xpack.uptime.navigateToAlertingButton.content', { - defaultMessage: 'Manage alerts', + defaultMessage: 'Manage rules', + }), + toggleAlertFlyoutButtonLabel: i18n.translate('xpack.uptime.alerts.createRulesPanel.title', { + defaultMessage: 'Create rules', }), }; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap index 115dab1095dc1..cfdf7afba4e85 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap @@ -1303,7 +1303,7 @@ exports[`MonitorList component renders the monitor list 1`] = ` >