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 bc6075176cd22..0a1f2d1bf8b4e 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 @@ -12,6 +12,9 @@ readonly links: { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly droppedTransactionSpans: string; + readonly upgrading: string; + readonly metaData: string; }; readonly canvas: { readonly guide: 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 aa3f958018041..4b6e1ad2631b4 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,6 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: 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;
}>;
readonly ecs: {
readonly guide: string;
};
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly upgrading: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: 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;
}>;
readonly ecs: {
readonly guide: string;
};
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: 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;
}>;
readonly ecs: {
readonly guide: string;
};
} | | diff --git a/package.json b/package.json index e603190c72698..5aabfc66e4637 100644 --- a/package.json +++ b/package.json @@ -348,7 +348,7 @@ "react-moment-proptypes": "^1.7.0", "react-monaco-editor": "^0.41.2", "react-popper-tooltip": "^2.10.1", - "react-query": "^3.21.0", + "react-query": "^3.21.1", "react-redux": "^7.2.0", "react-resizable": "^1.7.5", "react-resize-detector": "^4.2.0", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 77bbeabb7f73b..7e6a1a9350d81 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -112,7 +112,7 @@ pageLoadAssetSize: expressionImage: 19288 expressionMetric: 22238 expressionShape: 34008 - interactiveSetup: 70000 + interactiveSetup: 80000 expressionTagcloud: 27505 expressions: 239290 securitySolution: 231753 diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index f3ef7c550e57d..94a568b380e64 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -24,6 +24,7 @@ export class DocLinksService { 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}/`; + const APM_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/apm/`; return deepFreeze({ DOC_LINK_VERSION, @@ -33,6 +34,9 @@ export class DocLinksService { apm: { kibanaSettings: `${KIBANA_DOCS}apm-settings-in-kibana.html`, supportedServiceMaps: `${KIBANA_DOCS}service-maps.html#service-maps-supported`, + droppedTransactionSpans: `${APM_DOCS}get-started/${DOC_LINK_VERSION}/transaction-spans.html#dropped-spans`, + upgrading: `${APM_DOCS}server/${DOC_LINK_VERSION}/upgrading.html`, + metaData: `${APM_DOCS}get-started/${DOC_LINK_VERSION}/metadata.html`, }, canvas: { guide: `${KIBANA_DOCS}canvas.html`, @@ -458,6 +462,9 @@ export interface DocLinksStart { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly droppedTransactionSpans: string; + readonly upgrading: string; + readonly metaData: string; }; readonly canvas: { readonly guide: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index f18e1dc26bd87..7512bd723cfa0 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -476,6 +476,9 @@ export interface DocLinksStart { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly droppedTransactionSpans: string; + readonly upgrading: string; + readonly metaData: string; }; readonly canvas: { readonly guide: string; diff --git a/src/core/server/saved_objects/migrationsv2/test_helpers/retry.test.ts b/src/core/server/saved_objects/migrationsv2/test_helpers/retry.test.ts index 246f61c71ae4d..ff5bf3d01c641 100644 --- a/src/core/server/saved_objects/migrationsv2/test_helpers/retry.test.ts +++ b/src/core/server/saved_objects/migrationsv2/test_helpers/retry.test.ts @@ -8,7 +8,8 @@ import { retryAsync } from './retry_async'; -describe('retry', () => { +// FLAKY: https://github.com/elastic/kibana/issues/110970 +describe.skip('retry', () => { it('retries throwing functions until they succeed', async () => { let i = 0; await expect( diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 965a716098f33..9395c5fdf8834 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -23,6 +23,7 @@ export const storybookAliases = { expression_repeat_image: 'src/plugins/expression_repeat_image/.storybook', expression_reveal_image: 'src/plugins/expression_reveal_image/.storybook', expression_shape: 'src/plugins/expression_shape/.storybook', + expression_tagcloud: 'src/plugins/chart_expressions/expression_tagcloud/.storybook', infra: 'x-pack/plugins/infra/.storybook', security_solution: 'x-pack/plugins/security_solution/.storybook', ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', diff --git a/src/plugins/chart_expressions/expression_tagcloud/.storybook/main.js b/src/plugins/chart_expressions/expression_tagcloud/.storybook/main.js new file mode 100644 index 0000000000000..cb483d5394285 --- /dev/null +++ b/src/plugins/chart_expressions/expression_tagcloud/.storybook/main.js @@ -0,0 +1,23 @@ +/* + * 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 { defaultConfig } from '@kbn/storybook'; +import webpackMerge from 'webpack-merge'; +import { resolve } from 'path'; + +const mockConfig = { + resolve: { + alias: { + '../format_service': resolve(__dirname, '../public/__mocks__/format_service.ts'), + }, + }, +}; + +module.exports = { + ...defaultConfig, + webpackFinal: (config) => webpackMerge(config, mockConfig), +}; diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/__snapshots__/tagcloud_function.test.ts.snap b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/__snapshots__/tagcloud_function.test.ts.snap index 56b24f0ae004f..da116bc50f370 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/__snapshots__/tagcloud_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/__snapshots__/tagcloud_function.test.ts.snap @@ -4,23 +4,35 @@ exports[`interpreter/functions#tagcloud logs correct datatable to inspector 1`] Object { "columns": Array [ Object { - "id": "col-0-1", + "id": "Count", "meta": Object { "dimensionName": "Tag size", }, "name": "Count", }, + Object { + "id": "country", + "meta": Object { + "dimensionName": "Tags", + }, + "name": "country", + }, ], "rows": Array [ Object { - "col-0-1": 0, + "Count": 0, + "country": "US", + }, + Object { + "Count": 10, + "country": "UK", }, ], "type": "datatable", } `; -exports[`interpreter/functions#tagcloud returns an object with the correct structure 1`] = ` +exports[`interpreter/functions#tagcloud returns an object with the correct structure for number accessors 1`] = ` Object { "as": "tagcloud", "type": "render", @@ -29,13 +41,22 @@ Object { "visData": Object { "columns": Array [ Object { - "id": "col-0-1", + "id": "Count", "name": "Count", }, + Object { + "id": "country", + "name": "country", + }, ], "rows": Array [ Object { - "col-0-1": 0, + "Count": 0, + "country": "US", + }, + Object { + "Count": 10, + "country": "UK", }, ], "type": "datatable", @@ -43,16 +64,81 @@ Object { "visParams": Object { "bucket": Object { "accessor": 1, + }, + "maxFontSize": 72, + "metric": Object { + "accessor": 0, + }, + "minFontSize": 18, + "orientation": "single", + "palette": Object { + "name": "default", + "type": "palette", + }, + "scale": "linear", + "showLabel": true, + }, + "visType": "tagcloud", + }, +} +`; + +exports[`interpreter/functions#tagcloud returns an object with the correct structure for string accessors 1`] = ` +Object { + "as": "tagcloud", + "type": "render", + "value": Object { + "syncColors": false, + "visData": Object { + "columns": Array [ + Object { + "id": "Count", + "name": "Count", + }, + Object { + "id": "country", + "name": "country", + }, + ], + "rows": Array [ + Object { + "Count": 0, + "country": "US", + }, + Object { + "Count": 10, + "country": "UK", + }, + ], + "type": "datatable", + }, + "visParams": Object { + "bucket": Object { + "accessor": Object { + "id": "country", + "meta": Object { + "type": "string", + }, + "name": "country", + }, "format": Object { - "id": "number", + "params": Object {}, }, + "type": "vis_dimension", }, "maxFontSize": 72, "metric": Object { - "accessor": 0, + "accessor": Object { + "id": "Count", + "meta": Object { + "type": "number", + }, + "name": "Count", + }, "format": Object { - "id": "number", + "params": Object {}, }, + "type": "vis_dimension", }, "minFontSize": 18, "orientation": "single", diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts index 2c6e021b5107a..8abdc36704b45 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts @@ -9,14 +9,23 @@ import { tagcloudFunction } from './tagcloud_function'; import { functionWrapper } from '../../../../expressions/common/expression_functions/specs/tests/utils'; +import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { Datatable } from '../../../../expressions/common/expression_types/specs'; describe('interpreter/functions#tagcloud', () => { const fn = functionWrapper(tagcloudFunction()); + const column1 = 'Count'; + const column2 = 'country'; const context = { type: 'datatable', - rows: [{ 'col-0-1': 0 }], - columns: [{ id: 'col-0-1', name: 'Count' }], + columns: [ + { id: column1, name: column1 }, + { id: column2, name: column2 }, + ], + rows: [ + { [column1]: 0, [column2]: 'US' }, + { [column1]: 10, [column2]: 'UK' }, + ], }; const visConfig = { scale: 'linear', @@ -24,12 +33,52 @@ describe('interpreter/functions#tagcloud', () => { minFontSize: 18, maxFontSize: 72, showLabel: true, - metric: { accessor: 0, format: { id: 'number' } }, - bucket: { accessor: 1, format: { id: 'number' } }, }; - it('returns an object with the correct structure', () => { - const actual = fn(context, visConfig, undefined); + const numberAccessors = { + metric: { accessor: 0 }, + bucket: { accessor: 1 }, + }; + + const stringAccessors: { + metric: ExpressionValueVisDimension; + bucket: ExpressionValueVisDimension; + } = { + metric: { + type: 'vis_dimension', + accessor: { + id: column1, + name: column1, + meta: { + type: 'number', + }, + }, + format: { + params: {}, + }, + }, + bucket: { + type: 'vis_dimension', + accessor: { + id: column2, + name: column2, + meta: { + type: 'string', + }, + }, + format: { + params: {}, + }, + }, + }; + + it('returns an object with the correct structure for number accessors', () => { + const actual = fn(context, { ...visConfig, ...numberAccessors }, undefined); + expect(actual).toMatchSnapshot(); + }); + + it('returns an object with the correct structure for string accessors', () => { + const actual = fn(context, { ...visConfig, ...stringAccessors }, undefined); expect(actual).toMatchSnapshot(); }); @@ -44,7 +93,7 @@ describe('interpreter/functions#tagcloud', () => { }, }, }; - await fn(context, visConfig, handlers as any); + await fn(context, { ...visConfig, ...numberAccessors }, handlers as any); expect(loggedTable!).toMatchSnapshot(); }); diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts index c3553c4660ce9..2ce50e94aeda3 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { prepareLogTable, Dimension } from '../../../../visualizations/common/prepare_log_table'; -import { TagCloudVisParams } from '../types'; +import { TagCloudRendererParams } from '../types'; import { ExpressionTagcloudFunction } from '../types'; import { EXPRESSION_NAME } from '../constants'; @@ -125,7 +125,7 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => { }, }, fn(input, args, handlers) { - const visParams = { + const visParams: TagCloudRendererParams = { scale: args.scale, orientation: args.orientation, minFontSize: args.minFontSize, @@ -139,7 +139,7 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => { type: 'palette', name: args.palette, }, - } as TagCloudVisParams; + }; if (handlers?.inspectorAdapters?.tables) { const argsTable: Dimension[] = [[[args.metric], dimension.tagSize]]; diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts index b1aba30380b59..1ee0434e1603e 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts @@ -10,19 +10,10 @@ import { Datatable, ExpressionFunctionDefinition, ExpressionValueRender, - SerializedFieldFormat, } from '../../../../expressions'; import { ExpressionValueVisDimension } from '../../../../visualizations/common'; import { EXPRESSION_NAME } from '../constants'; -interface Dimension { - accessor: number; - format: { - id?: string; - params?: SerializedFieldFormat; - }; -} - interface TagCloudCommonParams { scale: 'linear' | 'log' | 'square root'; orientation: 'single' | 'right angled' | 'multiple'; @@ -36,16 +27,16 @@ export interface TagCloudVisConfig extends TagCloudCommonParams { bucket?: ExpressionValueVisDimension; } -export interface TagCloudVisParams extends TagCloudCommonParams { +export interface TagCloudRendererParams extends TagCloudCommonParams { palette: PaletteOutput; - metric: Dimension; - bucket?: Dimension; + metric: ExpressionValueVisDimension; + bucket?: ExpressionValueVisDimension; } export interface TagcloudRendererConfig { visType: typeof EXPRESSION_NAME; visData: Datatable; - visParams: TagCloudVisParams; + visParams: TagCloudRendererParams; syncColors: boolean; } diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/format_service.ts b/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/format_service.ts new file mode 100644 index 0000000000000..77f6d8eb0bf37 --- /dev/null +++ b/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/format_service.ts @@ -0,0 +1,13 @@ +/* + * 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 const getFormatService = () => ({ + deserialize: (target: any) => ({ + convert: (text: string, format: string) => text, + }), +}); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/palettes.ts b/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/palettes.ts new file mode 100644 index 0000000000000..7ca00b58f5624 --- /dev/null +++ b/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/palettes.ts @@ -0,0 +1,52 @@ +/* + * 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 { PaletteDefinition, SeriesLayer } from '../../../../charts/public'; +import { random } from 'lodash'; + +export const getPaletteRegistry = () => { + const colors = [ + '#54B399', + '#6092C0', + '#D36086', + '#9170B8', + '#CA8EAE', + '#D6BF57', + '#B9A888', + '#DA8B45', + '#AA6556', + '#E7664C', + ]; + const mockPalette: PaletteDefinition = { + id: 'default', + title: 'My Palette', + getCategoricalColor: (_: SeriesLayer[]) => colors[random(0, colors.length - 1)], + getCategoricalColors: (num: number) => colors, + toExpression: () => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: ['default'], + }, + }, + ], + }), + }; + + return { + get: (name: string) => mockPalette, + getAll: () => [mockPalette], + }; +}; + +export const palettes = { + getPalettes: async () => getPaletteRegistry(), +}; diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx new file mode 100644 index 0000000000000..1e0dc2600d1a1 --- /dev/null +++ b/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx @@ -0,0 +1,125 @@ +/* + * 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 { storiesOf } from '@storybook/react'; +import { tagcloudRenderer } from '../expression_renderers'; +import { Render } from '../../../../presentation_util/public/__stories__'; +import { TagcloudRendererConfig } from '../../common/types'; +import { palettes } from '../__mocks__/palettes'; + +const config: TagcloudRendererConfig = { + visType: 'tagcloud', + visData: { + type: 'datatable', + rows: [ + { country: 'US', Count: 14 }, + { country: 'JP', Count: 13 }, + { country: 'UK', Count: 13 }, + { country: 'CN', Count: 8 }, + { country: 'TZ', Count: 14 }, + { country: 'NL', Count: 11 }, + { country: 'AZ', Count: 14 }, + { country: 'BR', Count: 11 }, + { country: 'DE', Count: 16 }, + { country: 'SA', Count: 11 }, + { country: 'RU', Count: 9 }, + { country: 'IN', Count: 9 }, + { country: 'PH', Count: 7 }, + ], + columns: [ + { id: 'country', name: 'country', meta: { type: 'string' } }, + { id: 'Count', name: 'Count', meta: { type: 'number' } }, + ], + }, + visParams: { + scale: 'linear', + orientation: 'single', + minFontSize: 18, + maxFontSize: 72, + showLabel: true, + metric: { + type: 'vis_dimension', + accessor: { id: 'Count', name: 'Count', meta: { type: 'number' } }, + format: { id: 'string', params: {} }, + }, + bucket: { + type: 'vis_dimension', + accessor: { id: 'country', name: 'country', meta: { type: 'string' } }, + format: { id: 'string', params: {} }, + }, + palette: { type: 'palette', name: 'default' }, + }, + syncColors: false, +}; + +const containerSize = { + width: '700px', + height: '700px', +}; + +storiesOf('renderers/tag_cloud_vis', module) + .add('Default', () => { + return ( + tagcloudRenderer({ palettes })} config={config} {...containerSize} /> + ); + }) + .add('With log scale', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visParams: { ...config.visParams, scale: 'log' } }} + {...containerSize} + /> + ); + }) + .add('With square root scale', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visParams: { ...config.visParams, scale: 'square root' } }} + {...containerSize} + /> + ); + }) + .add('With right angled orientation', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visParams: { ...config.visParams, orientation: 'right angled' } }} + {...containerSize} + /> + ); + }) + .add('With multiple orientations', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visParams: { ...config.visParams, orientation: 'multiple' } }} + {...containerSize} + /> + ); + }) + .add('With hidden label', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visParams: { ...config.visParams, showLabel: false } }} + {...containerSize} + /> + ); + }) + .add('With empty results', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visData: { ...config.visData, rows: [] } }} + {...containerSize} + /> + ); + }); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tag_cloud.scss b/src/plugins/chart_expressions/expression_tagcloud/public/components/tag_cloud.scss index 51b5e9dedd844..8a017150fe195 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tag_cloud.scss +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tag_cloud.scss @@ -9,6 +9,8 @@ flex: 1 1 0; display: flex; flex-direction: column; + // it is used for rendering at `Canvas`. + height: 100%; } .tgcChart__wrapper text { diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx index 542a9c1cd9bf7..f65630e422cce 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx @@ -6,15 +6,15 @@ * Side Public License, v 1. */ import React from 'react'; -import { Wordcloud, Settings } from '@elastic/charts'; +import { Wordcloud, Settings, WordcloudSpec } from '@elastic/charts'; import { chartPluginMock } from '../../../../charts/public/mocks'; import type { Datatable } from '../../../../expressions/public'; import { mount } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import TagCloudChart, { TagCloudChartProps } from './tagcloud_component'; -import { TagCloudVisParams } from '../../common/types'; +import { TagCloudRendererParams } from '../../common/types'; -jest.mock('../services', () => ({ +jest.mock('../format_service', () => ({ getFormatService: jest.fn(() => { return { deserialize: jest.fn(), @@ -23,29 +23,34 @@ jest.mock('../services', () => ({ })); const palettesRegistry = chartPluginMock.createPaletteRegistry(); -const visData = ({ +const geoDestId = 'geo.dest'; +const countId = 'Count'; +const visData: Datatable = { + type: 'datatable', columns: [ { - id: 'col-0', - name: 'geo.dest: Descending', + id: geoDestId, + name: `${geoDestId}: Descending`, + meta: { type: 'string' }, }, { - id: 'col-1', + id: countId, name: 'Count', + meta: { type: 'number' }, }, ], rows: [ - { 'col-0': 'CN', 'col-1': 26 }, - { 'col-0': 'IN', 'col-1': 17 }, - { 'col-0': 'US', 'col-1': 6 }, - { 'col-0': 'DE', 'col-1': 4 }, - { 'col-0': 'BR', 'col-1': 3 }, + { [geoDestId]: 'CN', [countId]: 26 }, + { [geoDestId]: 'IN', [countId]: 17 }, + { [geoDestId]: 'US', [countId]: 6 }, + { [geoDestId]: 'DE', [countId]: 4 }, + { [geoDestId]: 'BR', [countId]: 3 }, ], -} as unknown) as Datatable; +}; -const visParams = { - bucket: { accessor: 0, format: {} }, - metric: { accessor: 1, format: {} }, +const visParams: TagCloudRendererParams = { + bucket: { type: 'vis_dimension', accessor: 0, format: { params: {} } }, + metric: { type: 'vis_dimension', accessor: 1, format: { params: {} } }, scale: 'linear', orientation: 'single', palette: { @@ -55,13 +60,42 @@ const visParams = { minFontSize: 12, maxFontSize: 70, showLabel: true, -} as TagCloudVisParams; +}; + +const formattedData: WordcloudSpec['data'] = [ + { + color: 'black', + text: 'CN', + weight: 1, + }, + { + color: 'black', + text: 'IN', + weight: 0.6086956521739131, + }, + { + color: 'black', + text: 'US', + weight: 0.13043478260869565, + }, + { + color: 'black', + text: 'DE', + weight: 0.043478260869565216, + }, + { + color: 'black', + text: 'BR', + weight: 0, + }, +]; describe('TagCloudChart', function () { - let wrapperProps: TagCloudChartProps; + let wrapperPropsWithIndexes: TagCloudChartProps; + let wrapperPropsWithColumnNames: TagCloudChartProps; beforeAll(() => { - wrapperProps = { + wrapperPropsWithIndexes = { visData, visParams, palettesRegistry, @@ -70,68 +104,77 @@ describe('TagCloudChart', function () { syncColors: false, visType: 'tagcloud', }; + + wrapperPropsWithColumnNames = { + visData, + visParams: { + ...visParams, + bucket: { + type: 'vis_dimension', + accessor: { + id: geoDestId, + name: geoDestId, + meta: { type: 'string' }, + }, + format: { id: 'string', params: {} }, + }, + metric: { + type: 'vis_dimension', + accessor: { + id: countId, + name: countId, + meta: { type: 'number' }, + }, + format: { id: 'number', params: {} }, + }, + }, + palettesRegistry, + fireEvent: jest.fn(), + renderComplete: jest.fn(), + syncColors: false, + visType: 'tagcloud', + }; }); - it('renders the Wordcloud component', async () => { - const component = mount(); + it('renders the Wordcloud component with', async () => { + const component = mount(); expect(component.find(Wordcloud).length).toBe(1); }); it('renders the label correctly', async () => { - const component = mount(); + const component = mount(); const label = findTestSubject(component, 'tagCloudLabel'); expect(label.text()).toEqual('geo.dest: Descending - Count'); }); it('not renders the label if showLabel setting is off', async () => { const newVisParams = { ...visParams, showLabel: false }; - const newProps = { ...wrapperProps, visParams: newVisParams }; + const newProps = { ...wrapperPropsWithIndexes, visParams: newVisParams }; const component = mount(); const label = findTestSubject(component, 'tagCloudLabel'); expect(label.length).toBe(0); }); - it('receives the data on the correct format', () => { - const component = mount(); - expect(component.find(Wordcloud).prop('data')).toStrictEqual([ - { - color: 'black', - text: 'CN', - weight: 1, - }, - { - color: 'black', - text: 'IN', - weight: 0.6086956521739131, - }, - { - color: 'black', - text: 'US', - weight: 0.13043478260869565, - }, - { - color: 'black', - text: 'DE', - weight: 0.043478260869565216, - }, - { - color: 'black', - text: 'BR', - weight: 0, - }, - ]); + it('receives the data in the correct format for bucket and metric accessors of type number', () => { + const component = mount(); + expect(component.find(Wordcloud).prop('data')).toStrictEqual(formattedData); + }); + + it('receives the data in the correct format for bucket and metric accessors of type DatatableColumn', () => { + const component = mount(); + expect(component.find(Wordcloud).prop('data')).toStrictEqual(formattedData); }); it('sets the angles correctly', async () => { - const newVisParams = { ...visParams, orientation: 'right angled' } as TagCloudVisParams; - const newProps = { ...wrapperProps, visParams: newVisParams }; + const newVisParams: TagCloudRendererParams = { ...visParams, orientation: 'right angled' }; + const newProps = { ...wrapperPropsWithIndexes, visParams: newVisParams }; const component = mount(); expect(component.find(Wordcloud).prop('endAngle')).toBe(90); expect(component.find(Wordcloud).prop('angleCount')).toBe(2); }); it('calls filter callback', () => { - const component = mount(); + const component = mount(); component.find(Settings).prop('onElementClick')!([ [ { @@ -145,6 +188,6 @@ describe('TagCloudChart', function () { }, ], ]); - expect(wrapperProps.fireEvent).toHaveBeenCalled(); + expect(wrapperPropsWithIndexes.fireEvent).toHaveBeenCalled(); }); }); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx index 163a2e8ce38ac..b7d38c71f5867 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx @@ -12,8 +12,13 @@ import { throttle } from 'lodash'; import { EuiIconTip, EuiResizeObserver } from '@elastic/eui'; import { Chart, Settings, Wordcloud, RenderChangeListener } from '@elastic/charts'; import type { PaletteRegistry } from '../../../../charts/public'; -import type { IInterpreterRenderHandlers } from '../../../../expressions/public'; -import { getFormatService } from '../services'; +import { + Datatable, + DatatableColumn, + IInterpreterRenderHandlers, +} from '../../../../expressions/public'; +import { getFormatService } from '../format_service'; +import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { TagcloudRendererConfig } from '../../common/types'; import './tag_cloud.scss'; @@ -68,6 +73,17 @@ const ORIENTATIONS = { }, }; +const getColumn = ( + accessor: ExpressionValueVisDimension['accessor'], + columns: Datatable['columns'] +): DatatableColumn => { + if (typeof accessor === 'number') { + return columns[accessor]; + } + + return columns.filter(({ id }) => id === accessor.id)[0]; +}; + export const TagCloudChart = ({ visData, visParams, @@ -81,18 +97,18 @@ export const TagCloudChart = ({ const bucketFormatter = bucket ? getFormatService().deserialize(bucket.format) : null; const tagCloudData = useMemo(() => { - const tagColumn = bucket ? visData.columns[bucket.accessor].id : -1; - const metricColumn = visData.columns[metric.accessor]?.id; + const tagColumn = bucket ? getColumn(bucket.accessor, visData.columns).id : null; + const metricColumn = getColumn(metric.accessor, visData.columns).id; const metrics = visData.rows.map((row) => row[metricColumn]); - const values = bucket ? visData.rows.map((row) => row[tagColumn]) : []; + const values = bucket && tagColumn !== null ? visData.rows.map((row) => row[tagColumn]) : []; const maxValue = Math.max(...metrics); const minValue = Math.min(...metrics); return visData.rows.map((row) => { - const tag = row[tagColumn] === undefined ? 'all' : row[tagColumn]; + const tag = tagColumn === null ? 'all' : row[tagColumn]; return { - text: (bucketFormatter ? bucketFormatter.convert(tag, 'text') : tag) as string, + text: bucketFormatter ? bucketFormatter.convert(tag, 'text') : tag, weight: tag === 'all' || visData.rows.length <= 1 ? 1 @@ -112,7 +128,9 @@ export const TagCloudChart = ({ ]); const label = bucket - ? `${visData.columns[bucket.accessor].name} - ${visData.columns[metric.accessor].name}` + ? `${getColumn(bucket.accessor, visData.columns).name} - ${ + getColumn(metric.accessor, visData.columns).name + }` : ''; const onRenderChange = useCallback( @@ -133,17 +151,17 @@ export const TagCloudChart = ({ ); const handleWordClick = useCallback( - (d) => { + (elements) => { if (!bucket) { return; } - const termsBucket = visData.columns[bucket.accessor]; - const clickedValue = d[0][0].text; + const termsBucketId = getColumn(bucket.accessor, visData.columns).id; + const clickedValue = elements[0][0].text; const rowIndex = visData.rows.findIndex((row) => { const formattedValue = bucketFormatter - ? bucketFormatter.convert(row[termsBucket.id], 'text') - : row[termsBucket.id]; + ? bucketFormatter.convert(row[termsBucketId], 'text') + : row[termsBucketId]; return formattedValue === clickedValue; }); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/expression_renderers/tagcloud_renderer.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/expression_renderers/tagcloud_renderer.tsx index 58e177dac6775..294371b3a5703 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/expression_renderers/tagcloud_renderer.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/expression_renderers/tagcloud_renderer.tsx @@ -5,15 +5,16 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + import React, { lazy } from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; +import { ClassNames } from '@emotion/react'; import { i18n } from '@kbn/i18n'; -import { ExpressionRenderDefinition } from '../../../../expressions/common'; import { VisualizationContainer } from '../../../../visualizations/public'; -import { withSuspense } from '../../../../presentation_util/public'; -import { TagcloudRendererConfig } from '../../common/types'; +import { ExpressionRenderDefinition } from '../../../../expressions/common/expression_renderers'; import { ExpressioTagcloudRendererDependencies } from '../plugin'; +import { TagcloudRendererConfig } from '../../common/types'; import { EXPRESSION_NAME } from '../../common'; export const strings = { @@ -27,8 +28,11 @@ export const strings = { }), }; -const LazyTagcloudComponent = lazy(() => import('../components/tagcloud_component')); -const TagcloudComponent = withSuspense(LazyTagcloudComponent); +const tagCloudVisClass = { + height: '100%', +}; + +const TagCloudChart = lazy(() => import('../components/tagcloud_component')); export const tagcloudRenderer: ( deps: ExpressioTagcloudRendererDependencies @@ -43,17 +47,29 @@ export const tagcloudRenderer: ( }); const palettesRegistry = await palettes.getPalettes(); + const showNoResult = config.visData.rows.length === 0; + render( - - - + + {({ css, cx }) => ( + + + + )} + , domNode ); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/services.ts b/src/plugins/chart_expressions/expression_tagcloud/public/format_service.ts similarity index 100% rename from src/plugins/chart_expressions/expression_tagcloud/public/services.ts rename to src/plugins/chart_expressions/expression_tagcloud/public/format_service.ts diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/plugin.ts b/src/plugins/chart_expressions/expression_tagcloud/public/plugin.ts index 7cbc9ac7c6706..9ffb910bde213 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/plugin.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/public/plugin.ts @@ -12,7 +12,7 @@ import { ChartsPluginSetup } from '../../../charts/public'; import { tagcloudRenderer } from './expression_renderers'; import { tagcloudFunction } from '../common/expression_functions'; import { FieldFormatsStart } from '../../../field_formats/public'; -import { setFormatService } from './services'; +import { setFormatService } from './format_service'; interface SetupDeps { expressions: ExpressionsSetup; diff --git a/src/plugins/interactive_setup/common/constants.ts b/src/plugins/interactive_setup/common/constants.ts new file mode 100644 index 0000000000000..00a3efc316cd9 --- /dev/null +++ b/src/plugins/interactive_setup/common/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 const VERIFICATION_CODE_LENGTH = 6; diff --git a/src/plugins/interactive_setup/common/index.ts b/src/plugins/interactive_setup/common/index.ts index ab8c00cfa5a8e..3833873eb2a18 100644 --- a/src/plugins/interactive_setup/common/index.ts +++ b/src/plugins/interactive_setup/common/index.ts @@ -8,3 +8,4 @@ export type { InteractiveSetupViewState, EnrollmentToken, Certificate, PingResult } from './types'; export { ElasticsearchConnectionStatus } from './elasticsearch_connection_status'; +export { VERIFICATION_CODE_LENGTH } from './constants'; diff --git a/src/plugins/interactive_setup/public/app.tsx b/src/plugins/interactive_setup/public/app.tsx index 0c206cb4fa215..da1318d84cf03 100644 --- a/src/plugins/interactive_setup/public/app.tsx +++ b/src/plugins/interactive_setup/public/app.tsx @@ -20,7 +20,11 @@ import { ClusterConfigurationForm } from './cluster_configuration_form'; import { EnrollmentTokenForm } from './enrollment_token_form'; import { ProgressIndicator } from './progress_indicator'; -export const App: FunctionComponent = () => { +export interface AppProps { + onSuccess?(): void; +} + +export const App: FunctionComponent = ({ onSuccess }) => { const [page, setPage] = useState<'token' | 'manual' | 'success'>('token'); const [cluster, setCluster] = useState< Omit @@ -71,9 +75,7 @@ export const App: FunctionComponent = () => { /> )} - {page === 'success' && ( - window.location.replace(window.location.href)} /> - )} + {page === 'success' && } diff --git a/src/plugins/interactive_setup/public/cluster_address_form.tsx b/src/plugins/interactive_setup/public/cluster_address_form.tsx index ba7b1d46182a1..6f97680066373 100644 --- a/src/plugins/interactive_setup/public/cluster_address_form.tsx +++ b/src/plugins/interactive_setup/public/cluster_address_form.tsx @@ -51,7 +51,7 @@ export const ClusterAddressForm: FunctionComponent = ({ const [form, eventHandlers] = useForm({ defaultValues, validate: async (values) => { - const errors: ValidationErrors = {}; + const errors: ValidationErrors = {}; if (!values.host) { errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostRequiredError', { diff --git a/src/plugins/interactive_setup/public/cluster_configuration_form.tsx b/src/plugins/interactive_setup/public/cluster_configuration_form.tsx index cd3541fe0318f..dfb5148ddb288 100644 --- a/src/plugins/interactive_setup/public/cluster_configuration_form.tsx +++ b/src/plugins/interactive_setup/public/cluster_configuration_form.tsx @@ -26,6 +26,7 @@ import { } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import React from 'react'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -37,6 +38,8 @@ import type { ValidationErrors } from './use_form'; import { useForm } from './use_form'; import { useHtmlId } from './use_html_id'; import { useHttp } from './use_http'; +import { useVerification } from './use_verification'; +import { useVisibility } from './use_visibility'; export interface ClusterConfigurationFormValues { username: string; @@ -66,10 +69,10 @@ export const ClusterConfigurationForm: FunctionComponent { const http = useHttp(); - + const { status, getCode } = useVerification(); const [form, eventHandlers] = useForm({ defaultValues, - validate: async (values) => { + validate: (values) => { const errors: ValidationErrors = {}; if (authRequired) { @@ -93,7 +96,7 @@ export const ClusterConfigurationForm: FunctionComponent(); const trustCaCertId = useHtmlId('clusterConfigurationForm', 'trustCaCert'); + useUpdateEffect(() => { + if (status === 'verified' && isVisible) { + form.submit(); + } + }, [status]); + return ( - {form.submitError && ( + {status !== 'unverified' && !form.isSubmitting && !form.isValidating && form.submitError && ( <> )} - - {authRequired ? ( <> )} - {certificateChain && certificateChain.length > 0 && ( <> { const intermediateCa = certificateChain[Math.min(1, certificateChain.length - 1)]; - form.setValue('caCert', form.values.caCert ? '' : intermediateCa.raw); form.setTouched('caCert'); + form.setValue('caCert', form.values.caCert ? '' : intermediateCa.raw); }} > @@ -252,7 +259,6 @@ export const ClusterConfigurationForm: FunctionComponent )} - @@ -264,6 +270,7 @@ export const ClusterConfigurationForm: FunctionComponent = onSuccess, }) => { const http = useHttp(); + const { status, getCode } = useVerification(); const [form, eventHandlers] = useForm({ defaultValues, validate: (values) => { @@ -77,17 +80,25 @@ export const EnrollmentTokenForm: FunctionComponent = hosts: decoded.adr, apiKey: decoded.key, caFingerprint: decoded.fgr, + code: getCode(), }), }); onSuccess?.(); }, }); + const [isVisible, buttonRef] = useVisibility(); + + useUpdateEffect(() => { + if (status === 'verified' && isVisible) { + form.submit(); + } + }, [status]); const enrollmentToken = decodeEnrollmentToken(form.values.token); return ( - {form.submitError && ( + {status !== 'unverified' && !form.isSubmitting && !form.isValidating && form.submitError && ( <> = = ( defaultMessage="Connect to" /> - - - - {token.adr[0]} - - - - - - + + {token.adr[0]} + diff --git a/src/plugins/interactive_setup/public/plugin.tsx b/src/plugins/interactive_setup/public/plugin.tsx index 00fd38d3e78a4..9d58479081234 100644 --- a/src/plugins/interactive_setup/public/plugin.tsx +++ b/src/plugins/interactive_setup/public/plugin.tsx @@ -15,6 +15,7 @@ import type { CoreSetup, CoreStart, HttpSetup, Plugin } from 'src/core/public'; import { App } from './app'; import { HttpProvider } from './use_http'; +import { VerificationProvider } from './use_verification'; export class InteractiveSetupPlugin implements Plugin { public setup(core: CoreSetup) { @@ -24,9 +25,16 @@ export class InteractiveSetupPlugin implements Plugin { appRoute: '/', chromeless: true, mount: (params) => { + const url = new URL(window.location.href); + const defaultCode = url.searchParams.get('code') || undefined; + const onSuccess = () => { + url.searchParams.delete('code'); + window.location.replace(url.href); + }; + ReactDOM.render( - - + + , params.element ); @@ -40,10 +48,13 @@ export class InteractiveSetupPlugin implements Plugin { export interface ProvidersProps { http: HttpSetup; + defaultCode?: string; } -export const Providers: FunctionComponent = ({ http, children }) => ( +export const Providers: FunctionComponent = ({ defaultCode, http, children }) => ( - {children} + + {children} + ); diff --git a/src/plugins/interactive_setup/public/single_chars_field.tsx b/src/plugins/interactive_setup/public/single_chars_field.tsx new file mode 100644 index 0000000000000..8d5cd2854c0aa --- /dev/null +++ b/src/plugins/interactive_setup/public/single_chars_field.tsx @@ -0,0 +1,137 @@ +/* + * 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 { EuiFieldText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { FunctionComponent, ReactNode } from 'react'; +import React, { useEffect, useRef } from 'react'; +import useList from 'react-use/lib/useList'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; + +import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; + +export interface SingleCharsFieldProps { + defaultValue: string; + length: number; + separator?: number; + pattern?: RegExp; + onChange(value: string): void; + isInvalid?: boolean; + autoFocus?: boolean; +} + +export const SingleCharsField: FunctionComponent = ({ + defaultValue, + length, + separator, + pattern = /^[0-9]$/, + onChange, + isInvalid, + autoFocus = false, +}) => { + // Strip any invalid characters from input or clipboard and restrict length. + const sanitize = (str: string) => { + return str + .split('') + .filter((char) => char.match(pattern)) + .join('') + .substr(0, length); + }; + + const inputRefs = useRef>([]); + const [chars, { set, updateAt }] = useList(sanitize(defaultValue).split('')); + + const focusField = (i: number) => { + const input = inputRefs.current[i]; + if (input) { + input.focus(); + } + }; + + // Trigger `onChange` callback when characters change + useUpdateEffect(() => { + onChange(chars.join('')); + }, [chars]); + + // Focus first field on initial render + useEffect(() => { + if (autoFocus) { + focusField(0); + } + }, [autoFocus]); + + const children: ReactNode[] = []; + for (let i = 0; i < length; i++) { + if (separator && i !== 0 && i % separator === 0) { + children.push( + + ); + } + + children.push( + + { + inputRefs.current[i] = el; + }} + value={chars[i] ?? ''} + onChange={(event) => { + // Ensure event doesn't bubble up since we manage our own `onChange` event + event.stopPropagation(); + }} + onInput={(event) => { + // Ignore input if invalid character was entered (unless empty) + if (event.currentTarget.value !== '' && sanitize(event.currentTarget.value) === '') { + return event.preventDefault(); + } + updateAt(i, event.currentTarget.value); + // Do not focus the next field if value is empty (e.g. when hitting backspace) + if (event.currentTarget.value) { + focusField(i + 1); + } + }} + onKeyDown={(event) => { + if (event.key === 'Backspace') { + // Clear previous field if current field is already empty + if (event.currentTarget.value === '') { + updateAt(i - 1, event.currentTarget.value); + focusField(i - 1); + } + } else if (event.key === 'ArrowLeft') { + focusField(i - 1); + } else if (event.key === 'ArrowRight') { + focusField(i + 1); + } + }} + onPaste={(event) => { + const text = sanitize(event.clipboardData.getData('text')); + set(text.split('')); + focusField(Math.min(text.length, length - 1)); + event.preventDefault(); + }} + onFocus={(event) => { + const input = event.currentTarget; + setTimeout(() => input.select(), 0); + }} + maxLength={1} + isInvalid={isInvalid} + style={{ textAlign: 'center' }} + /> + + ); + } + + return ( + + {children} + + ); +}; diff --git a/src/plugins/interactive_setup/public/use_form.ts b/src/plugins/interactive_setup/public/use_form.ts index 8ed1d89ea087e..abd00edee6750 100644 --- a/src/plugins/interactive_setup/public/use_form.ts +++ b/src/plugins/interactive_setup/public/use_form.ts @@ -9,7 +9,7 @@ import { set } from '@elastic/safer-lodash-set'; import { cloneDeep, cloneDeepWith, get } from 'lodash'; import type { ChangeEventHandler, FocusEventHandler, ReactEventHandler } from 'react'; -import { useRef } from 'react'; +import { useState } from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; export type FormReturnTuple = [FormState, FormProps]; @@ -81,12 +81,11 @@ export type ValidationErrors = DeepMap; export type TouchedFields = DeepMap; export interface FormState { - setValue(name: string, value: any, revalidate?: boolean): Promise; + setValue(name: string, value: any): Promise; setError(name: string, message: string): void; - setTouched(name: string, touched?: boolean, revalidate?: boolean): Promise; - reset(values: Values): void; + setTouched(name: string): Promise; + reset(values?: Values): void; submit(): Promise; - validate(): Promise>; values: Values; errors: ValidationErrors; touched: TouchedFields; @@ -123,63 +122,75 @@ export function useFormState({ validate, defaultValues, }: FormOptions): FormState { - const valuesRef = useRef(defaultValues); - const errorsRef = useRef>({}); - const touchedRef = useRef>({}); - const submitCountRef = useRef(0); - - const [validationState, validateForm] = useAsyncFn(async (formValues: Values) => { + const [values, setValues] = useState(defaultValues); + const [errors, setErrors] = useState>({}); + const [touched, setTouched] = useState>({}); + const [submitCount, setSubmitCount] = useState(0); + + async function validateFormFn(formValues: Values): Promise; + async function validateFormFn(formValues: undefined): Promise; + async function validateFormFn(formValues: Values | undefined) { + // Allows resetting `useAsyncFn` state + if (!formValues) { + return Promise.resolve(undefined); + } const nextErrors = await validate(formValues); - errorsRef.current = nextErrors; + setErrors(nextErrors); if (Object.keys(nextErrors).length === 0) { - submitCountRef.current = 0; + setSubmitCount(0); } return nextErrors; - }, []); + } - const [submitState, submitForm] = useAsyncFn(async (formValues: Values) => { + async function submitFormFn(formValues: Values): Promise; + async function submitFormFn(formValues: undefined): Promise; + async function submitFormFn(formValues: Values | undefined) { + // Allows resetting `useAsyncFn` state + if (!formValues) { + return Promise.resolve(undefined); + } const nextErrors = await validateForm(formValues); - touchedRef.current = mapDeep(formValues, true); - submitCountRef.current += 1; + setTouched(mapDeep(formValues, true)); + setSubmitCount(submitCount + 1); if (Object.keys(nextErrors).length === 0) { return onSubmit(formValues); } - }, []); + } + + const [validationState, validateForm] = useAsyncFn(validateFormFn, [validate]); + const [submitState, submitForm] = useAsyncFn(submitFormFn, [validateForm, onSubmit]); return { - setValue: async (name, value, revalidate = true) => { - const nextValues = setDeep(valuesRef.current, name, value); - valuesRef.current = nextValues; - if (revalidate) { - await validateForm(nextValues); - } + setValue: async (name, value) => { + const nextValues = setDeep(values, name, value); + setValues(nextValues); + await validateForm(nextValues); }, - setTouched: async (name, touched = true, revalidate = true) => { - touchedRef.current = setDeep(touchedRef.current, name, touched); - if (revalidate) { - await validateForm(valuesRef.current); - } + setTouched: async (name, value = true) => { + setTouched(setDeep(touched, name, value)); + await validateForm(values); }, setError: (name, message) => { - errorsRef.current = setDeep(errorsRef.current, name, message); - touchedRef.current = setDeep(touchedRef.current, name, true); + setErrors(setDeep(errors, name, message)); + setTouched(setDeep(touched, name, true)); }, - reset: (nextValues) => { - valuesRef.current = nextValues; - errorsRef.current = {}; - touchedRef.current = {}; - submitCountRef.current = 0; + reset: (nextValues = defaultValues) => { + setValues(nextValues); + setErrors({}); + setTouched({}); + setSubmitCount(0); + validateForm(undefined); // Resets `validationState` + submitForm(undefined); // Resets `submitState` }, - submit: () => submitForm(valuesRef.current), - validate: () => validateForm(valuesRef.current), - values: valuesRef.current, - errors: errorsRef.current, - touched: touchedRef.current, + submit: () => submitForm(values), + values, + errors, + touched, isValidating: validationState.loading, isSubmitting: submitState.loading, submitError: submitState.error, - isInvalid: Object.keys(errorsRef.current).length > 0, - isSubmitted: submitCountRef.current > 0, + isInvalid: Object.keys(errors).length > 0, + isSubmitted: submitCount > 0, }; } diff --git a/src/plugins/interactive_setup/public/use_verification.tsx b/src/plugins/interactive_setup/public/use_verification.tsx new file mode 100644 index 0000000000000..62483ba9cb62e --- /dev/null +++ b/src/plugins/interactive_setup/public/use_verification.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 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 { EuiModal, EuiModalHeader } from '@elastic/eui'; +import constate from 'constate'; +import type { FunctionComponent } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; + +import { useHttp } from './use_http'; +import { VerificationCodeForm } from './verification_code_form'; + +export interface VerificationProps { + defaultCode?: string; +} + +const [OuterVerificationProvider, useVerification] = constate( + ({ defaultCode }: VerificationProps) => { + const codeRef = useRef(defaultCode); + const [status, setStatus] = useState<'unknown' | 'unverified' | 'verified'>('unknown'); + + return { + status, + setStatus, + getCode() { + return codeRef.current; + }, + setCode(code: string | undefined) { + codeRef.current = code; + }, + }; + } +); + +const InnerVerificationProvider: FunctionComponent = ({ children }) => { + const http = useHttp(); + const { status, setStatus, setCode } = useVerification(); + + useEffect(() => { + return http.intercept({ + responseError: (error) => { + if (error.response?.status === 403) { + setStatus('unverified'); + setCode(undefined); + } + }, + }); + }, [http]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <> + {status === 'unverified' && ( + setStatus('unknown')}> + + { + setStatus('verified'); + setCode(values.code); + }} + /> + + + )} + {children} + + ); +}; + +export const VerificationProvider: FunctionComponent = ({ + defaultCode, + children, +}) => { + return ( + + {children} + + ); +}; + +export { useVerification }; diff --git a/src/plugins/interactive_setup/public/use_visibility.ts b/src/plugins/interactive_setup/public/use_visibility.ts new file mode 100644 index 0000000000000..f21b5669a36aa --- /dev/null +++ b/src/plugins/interactive_setup/public/use_visibility.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { RefObject } from 'react'; +import { useRef } from 'react'; + +export type VisibilityReturnTuple = [boolean, RefObject]; + +export function useVisibility(): VisibilityReturnTuple { + const elementRef = useRef(null); + + // When an element is hidden using `display: none` or `hidden` attribute it has no offset parent. + return [!!elementRef.current?.offsetParent, elementRef]; +} diff --git a/src/plugins/interactive_setup/public/verification_code_form.tsx b/src/plugins/interactive_setup/public/verification_code_form.tsx new file mode 100644 index 0000000000000..8f4a9ea8c5d01 --- /dev/null +++ b/src/plugins/interactive_setup/public/verification_code_form.tsx @@ -0,0 +1,153 @@ +/* + * 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 { + EuiButton, + EuiCallOut, + EuiEmptyPrompt, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { IHttpFetchError } from 'kibana/public'; + +import { VERIFICATION_CODE_LENGTH } from '../common'; +import { SingleCharsField } from './single_chars_field'; +import type { ValidationErrors } from './use_form'; +import { useForm } from './use_form'; +import { useHttp } from './use_http'; + +export interface VerificationCodeFormValues { + code: string; +} + +export interface VerificationCodeFormProps { + defaultValues?: VerificationCodeFormValues; + onSuccess?(values: VerificationCodeFormValues): void; +} + +export const VerificationCodeForm: FunctionComponent = ({ + defaultValues = { + code: '', + }, + onSuccess, +}) => { + const http = useHttp(); + const [form, eventHandlers] = useForm({ + defaultValues, + validate: async (values) => { + const errors: ValidationErrors = {}; + + if (!values.code) { + errors.code = i18n.translate('interactiveSetup.verificationCodeForm.codeRequiredError', { + defaultMessage: 'Enter a verification code.', + }); + } else if (values.code.length !== VERIFICATION_CODE_LENGTH) { + errors.code = i18n.translate('interactiveSetup.verificationCodeForm.codeMinLengthError', { + defaultMessage: 'Enter all six digits.', + }); + } + + return errors; + }, + onSubmit: async (values) => { + try { + await http.post('/internal/interactive_setup/verify', { + body: JSON.stringify({ + code: values.code, + }), + }); + } catch (error) { + if (error.response?.status === 403) { + form.setError('code', error.body?.message); + return; + } else { + throw error; + } + } + onSuccess?.(values); + }, + }); + + return ( + + + + + } + body={ + <> + {form.submitError && ( + <> + + {(form.submitError as IHttpFetchError).body?.message} + + + + )} + +

+ +

+
+ + + + form.setValue('code', value)} + isInvalid={form.touched.code && !!form.errors.code} + autoFocus + /> + + + } + actions={ + + + + } + /> +
+ ); +}; diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.ts b/src/plugins/interactive_setup/server/kibana_config_writer.ts index a59aa7640caa6..6271e2d78471f 100644 --- a/src/plugins/interactive_setup/server/kibana_config_writer.ts +++ b/src/plugins/interactive_setup/server/kibana_config_writer.ts @@ -75,7 +75,7 @@ export class KibanaConfigWriter { } } - const config: Record = { 'elasticsearch.hosts': [params.host] }; + const config: Record = { 'elasticsearch.hosts': [params.host] }; if ('serviceAccountToken' in params) { config['elasticsearch.serviceAccountToken'] = params.serviceAccountToken.value; } else if ('username' in params) { diff --git a/src/plugins/interactive_setup/server/plugin.ts b/src/plugins/interactive_setup/server/plugin.ts index 91a151e17b697..7f4d36385b28c 100644 --- a/src/plugins/interactive_setup/server/plugin.ts +++ b/src/plugins/interactive_setup/server/plugin.ts @@ -17,10 +17,12 @@ import type { ConfigSchema, ConfigType } from './config'; import { ElasticsearchService } from './elasticsearch_service'; import { KibanaConfigWriter } from './kibana_config_writer'; import { defineRoutes } from './routes'; +import { VerificationCode } from './verification_code'; export class InteractiveSetupPlugin implements PrebootPlugin { readonly #logger: Logger; readonly #elasticsearch: ElasticsearchService; + readonly #verificationCode: VerificationCode; #elasticsearchConnectionStatusSubscription?: Subscription; @@ -38,6 +40,9 @@ export class InteractiveSetupPlugin implements PrebootPlugin { this.#elasticsearch = new ElasticsearchService( this.initializerContext.logger.get('elasticsearch') ); + this.#verificationCode = new VerificationCode( + this.initializerContext.logger.get('verification') + ); } public setup(core: CorePreboot) { @@ -92,13 +97,18 @@ export class InteractiveSetupPlugin implements PrebootPlugin { this.#logger.debug( 'Starting interactive setup mode since Kibana cannot to connect to Elasticsearch at http://localhost:9200.' ); - const serverInfo = core.http.getServerInfo(); - const url = `${serverInfo.protocol}://${serverInfo.hostname}:${serverInfo.port}`; - this.#logger.info(` + const { code } = this.#verificationCode; + const pathname = core.http.basePath.prepend('/'); + const { protocol, hostname, port } = core.http.getServerInfo(); + const url = `${protocol}://${hostname}:${port}${pathname}?code=${code}`; + + // eslint-disable-next-line no-console + console.log(` ${chalk.whiteBright.bold(`${chalk.cyanBright('i')} Kibana has not been configured.`)} Go to ${chalk.cyanBright.underline(url)} to get started. + `); } } @@ -118,6 +128,7 @@ Go to ${chalk.cyanBright.underline(url)} to get started. preboot: { ...core.preboot, completeSetup }, kibanaConfigWriter: new KibanaConfigWriter(configPath, this.#logger.get('kibana-config')), elasticsearch, + verificationCode: this.#verificationCode, getConfig: this.#getConfig.bind(this), }); }); diff --git a/src/plugins/interactive_setup/server/routes/configure.test.ts b/src/plugins/interactive_setup/server/routes/configure.test.ts index d6b7404fce516..ac4507331db4b 100644 --- a/src/plugins/interactive_setup/server/routes/configure.test.ts +++ b/src/plugins/interactive_setup/server/routes/configure.test.ts @@ -57,7 +57,11 @@ describe('Configure routes', () => { expect(() => bodySchema.validate({ host: 'localhost:9200' }) ).toThrowErrorMatchingInlineSnapshot(`"[host]: expected URI with scheme [http|https]."`); - expect(() => bodySchema.validate({ host: 'http://localhost:9200' })).not.toThrowError(); + expect(bodySchema.validate({ host: 'http://localhost:9200' })).toMatchInlineSnapshot(` + Object { + "host": "http://localhost:9200", + } + `); expect(() => bodySchema.validate({ host: 'http://localhost:9200', username: 'elastic' }) ).toThrowErrorMatchingInlineSnapshot( @@ -71,21 +75,57 @@ describe('Configure routes', () => { expect(() => bodySchema.validate({ host: 'http://localhost:9200', password: 'password' }) ).toThrowErrorMatchingInlineSnapshot(`"[password]: a value wasn't expected to be present"`); - expect(() => + expect( bodySchema.validate({ host: 'http://localhost:9200', username: 'kibana_system', password: '', }) - ).not.toThrowError(); + ).toMatchInlineSnapshot(` + Object { + "host": "http://localhost:9200", + "password": "", + "username": "kibana_system", + } + `); expect(() => bodySchema.validate({ host: 'https://localhost:9200' }) ).toThrowErrorMatchingInlineSnapshot( `"[caCert]: expected value of type [string] but got [undefined]"` ); - expect(() => - bodySchema.validate({ host: 'https://localhost:9200', caCert: 'der' }) - ).not.toThrowError(); + expect(bodySchema.validate({ host: 'https://localhost:9200', caCert: 'der' })) + .toMatchInlineSnapshot(` + Object { + "caCert": "der", + "host": "https://localhost:9200", + } + `); + expect(bodySchema.validate({ host: 'https://localhost:9200', caCert: 'der', code: '123456' })) + .toMatchInlineSnapshot(` + Object { + "caCert": "der", + "code": "123456", + "host": "https://localhost:9200", + } + `); + }); + + it('fails if verification code is invalid.', async () => { + mockRouteParams.verificationCode.verify.mockReturnValue(false); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { host: 'host1' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual( + expect.objectContaining({ + status: 403, + }) + ); + + expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled(); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); }); it('fails if setup is not on hold.', async () => { diff --git a/src/plugins/interactive_setup/server/routes/configure.ts b/src/plugins/interactive_setup/server/routes/configure.ts index a34af0296ea04..75499d048cf93 100644 --- a/src/plugins/interactive_setup/server/routes/configure.ts +++ b/src/plugins/interactive_setup/server/routes/configure.ts @@ -21,15 +21,13 @@ export function defineConfigureRoute({ logger, kibanaConfigWriter, elasticsearch, + verificationCode, preboot, }: RouteDefinitionParams) { router.post( { path: '/internal/interactive_setup/configure', validate: { - query: schema.object({ - code: schema.maybe(schema.string()), - }), body: schema.object({ host: schema.uri({ scheme: ['http', 'https'] }), username: schema.maybe( @@ -56,11 +54,16 @@ export function defineConfigureRoute({ schema.string(), schema.never() ), + code: schema.maybe(schema.string()), }), }, options: { authRequired: false }, }, async (context, request, response) => { + if (!verificationCode.verify(request.body.code)) { + return response.forbidden(); + } + if (!preboot.isSetupOnHold()) { logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`); return response.badRequest({ body: 'Cannot process request outside of preboot stage.' }); diff --git a/src/plugins/interactive_setup/server/routes/enroll.test.ts b/src/plugins/interactive_setup/server/routes/enroll.test.ts index e42248704134a..859c3fb70ce83 100644 --- a/src/plugins/interactive_setup/server/routes/enroll.test.ts +++ b/src/plugins/interactive_setup/server/routes/enroll.test.ts @@ -95,18 +95,55 @@ describe('Enroll routes', () => { ); expect( - bodySchema.validate( - bodySchema.validate({ - apiKey: 'some-key', - hosts: ['https://localhost:9200'], - caFingerprint: 'a'.repeat(64), - }) - ) - ).toEqual({ - apiKey: 'some-key', - hosts: ['https://localhost:9200'], - caFingerprint: 'a'.repeat(64), + bodySchema.validate({ + apiKey: 'some-key', + hosts: ['https://localhost:9200'], + caFingerprint: 'a'.repeat(64), + }) + ).toMatchInlineSnapshot(` + Object { + "apiKey": "some-key", + "caFingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "hosts": Array [ + "https://localhost:9200", + ], + } + `); + expect( + bodySchema.validate({ + apiKey: 'some-key', + hosts: ['https://localhost:9200'], + caFingerprint: 'a'.repeat(64), + code: '123456', + }) + ).toMatchInlineSnapshot(` + Object { + "apiKey": "some-key", + "caFingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "code": "123456", + "hosts": Array [ + "https://localhost:9200", + ], + } + `); + }); + + it('fails if verification code is invalid.', async () => { + mockRouteParams.verificationCode.verify.mockReturnValue(false); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' }, }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual( + expect.objectContaining({ + status: 403, + }) + ); + + expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled(); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); }); it('fails if setup is not on hold.', async () => { diff --git a/src/plugins/interactive_setup/server/routes/enroll.ts b/src/plugins/interactive_setup/server/routes/enroll.ts index 41291246802e6..769d763a7d45d 100644 --- a/src/plugins/interactive_setup/server/routes/enroll.ts +++ b/src/plugins/interactive_setup/server/routes/enroll.ts @@ -23,6 +23,7 @@ export function defineEnrollRoutes({ logger, kibanaConfigWriter, elasticsearch, + verificationCode, preboot, }: RouteDefinitionParams) { router.post( @@ -35,11 +36,16 @@ export function defineEnrollRoutes({ }), apiKey: schema.string({ minLength: 1 }), caFingerprint: schema.string({ maxLength: 64, minLength: 64 }), + code: schema.maybe(schema.string()), }), }, options: { authRequired: false }, }, async (context, request, response) => { + if (!verificationCode.verify(request.body.code)) { + return response.forbidden(); + } + if (!preboot.isSetupOnHold()) { logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`); return response.badRequest({ body: 'Cannot process request outside of preboot stage.' }); diff --git a/src/plugins/interactive_setup/server/routes/index.mock.ts b/src/plugins/interactive_setup/server/routes/index.mock.ts index 249d1277269e7..15ec86031b6f2 100644 --- a/src/plugins/interactive_setup/server/routes/index.mock.ts +++ b/src/plugins/interactive_setup/server/routes/index.mock.ts @@ -11,6 +11,7 @@ import { coreMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mo import { ConfigSchema } from '../config'; import { elasticsearchServiceMock } from '../elasticsearch_service.mock'; import { kibanaConfigWriterMock } from '../kibana_config_writer.mock'; +import { verificationCodeMock } from '../verification_code.mock'; export const routeDefinitionParamsMock = { create: (config: Record = {}) => ({ @@ -21,6 +22,7 @@ export const routeDefinitionParamsMock = { preboot: { ...coreMock.createPreboot().preboot, completeSetup: jest.fn() }, getConfig: jest.fn().mockReturnValue(ConfigSchema.validate(config)), elasticsearch: elasticsearchServiceMock.createSetup(), + verificationCode: verificationCodeMock.create(), kibanaConfigWriter: kibanaConfigWriterMock.create(), }), }; diff --git a/src/plugins/interactive_setup/server/routes/index.ts b/src/plugins/interactive_setup/server/routes/index.ts index 75c383176e7e9..fb9e06c4c2a18 100644 --- a/src/plugins/interactive_setup/server/routes/index.ts +++ b/src/plugins/interactive_setup/server/routes/index.ts @@ -6,15 +6,17 @@ * Side Public License, v 1. */ -import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { PublicContract, PublicMethodsOf } from '@kbn/utility-types'; import type { IBasePath, IRouter, Logger, PrebootServicePreboot } from 'src/core/server'; import type { ConfigType } from '../config'; import type { ElasticsearchServiceSetup } from '../elasticsearch_service'; import type { KibanaConfigWriter } from '../kibana_config_writer'; +import type { VerificationCode } from '../verification_code'; import { defineConfigureRoute } from './configure'; import { defineEnrollRoutes } from './enroll'; import { definePingRoute } from './ping'; +import { defineVerifyRoute } from './verify'; /** * Describes parameters used to define HTTP routes. @@ -28,11 +30,13 @@ export interface RouteDefinitionParams { }; readonly kibanaConfigWriter: PublicMethodsOf; readonly elasticsearch: ElasticsearchServiceSetup; + readonly verificationCode: PublicContract; readonly getConfig: () => ConfigType; } export function defineRoutes(params: RouteDefinitionParams) { - defineEnrollRoutes(params); defineConfigureRoute(params); + defineEnrollRoutes(params); definePingRoute(params); + defineVerifyRoute(params); } diff --git a/src/plugins/interactive_setup/server/routes/verify.test.ts b/src/plugins/interactive_setup/server/routes/verify.test.ts new file mode 100644 index 0000000000000..ff8a7753320c2 --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/verify.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { ObjectType } from '@kbn/config-schema'; +import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { routeDefinitionParamsMock } from './index.mock'; +import { defineVerifyRoute } from './verify'; + +describe('Configure routes', () => { + let router: jest.Mocked; + let mockRouteParams: ReturnType; + let mockContext: RequestHandlerContext; + beforeEach(() => { + mockRouteParams = routeDefinitionParamsMock.create(); + router = mockRouteParams.router; + + mockContext = ({} as unknown) as RequestHandlerContext; + + defineVerifyRoute(mockRouteParams); + }); + + describe('#verify', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + + beforeEach(() => { + const [verifyRouteConfig, verifyRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/interactive_setup/verify' + )!; + + routeConfig = verifyRouteConfig; + routeHandler = verifyRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + + const bodySchema = (routeConfig.validate as any).body as ObjectType; + expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[code]: expected value of type [string] but got [undefined]"` + ); + expect(bodySchema.validate({ code: '123456' })).toMatchInlineSnapshot(` + Object { + "code": "123456", + } + `); + }); + + it('fails if verification code is invalid.', async () => { + mockRouteParams.verificationCode.verify.mockReturnValue(false); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { code: '123456' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual( + expect.objectContaining({ + status: 403, + }) + ); + }); + + it('succeeds if verification code is valid.', async () => { + mockRouteParams.verificationCode.verify.mockReturnValue(true); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { code: '123456' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual( + expect.objectContaining({ + status: 204, + }) + ); + }); + }); +}); diff --git a/src/plugins/interactive_setup/server/routes/verify.ts b/src/plugins/interactive_setup/server/routes/verify.ts new file mode 100644 index 0000000000000..ebdbb58ed9530 --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/verify.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 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 { schema } from '@kbn/config-schema'; + +import type { RouteDefinitionParams } from '.'; + +export function defineVerifyRoute({ router, verificationCode }: RouteDefinitionParams) { + router.post( + { + path: '/internal/interactive_setup/verify', + validate: { + body: schema.object({ + code: schema.string(), + }), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + if (!verificationCode.verify(request.body.code)) { + return response.forbidden({ + body: { + message: verificationCode.remainingAttempts + ? 'Invalid verification code.' + : 'Maximum number of attempts exceeded. Restart Kibana to generate a new code and retry.', + attributes: { + remainingAttempts: verificationCode.remainingAttempts, + }, + }, + }); + } + + return response.noContent(); + } + ); +} diff --git a/src/plugins/interactive_setup/server/verification_code.mock.ts b/src/plugins/interactive_setup/server/verification_code.mock.ts new file mode 100644 index 0000000000000..d4e9fc2028590 --- /dev/null +++ b/src/plugins/interactive_setup/server/verification_code.mock.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { PublicContract } from '@kbn/utility-types'; + +import type { VerificationCode } from './verification_code'; + +export const verificationCodeMock = { + create: (): jest.Mocked> => ({ + code: '123456', + remainingAttempts: 5, + verify: jest.fn().mockReturnValue(true), + }), +}; diff --git a/src/plugins/interactive_setup/server/verification_code.test.ts b/src/plugins/interactive_setup/server/verification_code.test.ts new file mode 100644 index 0000000000000..7387f285a2f62 --- /dev/null +++ b/src/plugins/interactive_setup/server/verification_code.test.ts @@ -0,0 +1,71 @@ +/* + * 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 { loggingSystemMock } from 'src/core/server/mocks'; + +import { VERIFICATION_CODE_LENGTH } from '../common'; +import { VerificationCode } from './verification_code'; + +const loggerMock = loggingSystemMock.createLogger(); + +describe('VerificationCode', () => { + it('should generate a 6 digit code', () => { + for (let i = 0; i < 10; i++) { + const { code } = new VerificationCode(loggerMock); + expect(code).toHaveLength(VERIFICATION_CODE_LENGTH); + expect(code).toEqual(expect.stringMatching(/^[0-9]+$/)); + } + }); + + it('should verify code correctly', () => { + const verificationCode = new VerificationCode(loggerMock); + + expect(verificationCode.verify(undefined)).toBe(false); + expect(verificationCode.verify('')).toBe(false); + expect(verificationCode.verify('invalid')).toBe(false); + expect(verificationCode.verify(verificationCode.code)).toBe(true); + }); + + it('should track number of failed attempts', () => { + const verificationCode = new VerificationCode(loggerMock); + + verificationCode.verify('invalid'); + verificationCode.verify('invalid'); + verificationCode.verify('invalid'); + expect(verificationCode['failedAttempts']).toBe(3); // eslint-disable-line dot-notation + }); + + it('should reset number of failed attempts if valid code is entered', () => { + const verificationCode = new VerificationCode(loggerMock); + + verificationCode.verify('invalid'); + verificationCode.verify('invalid'); + verificationCode.verify('invalid'); + expect(verificationCode.verify(verificationCode.code)).toBe(true); + expect(verificationCode['failedAttempts']).toBe(0); // eslint-disable-line dot-notation + }); + + it('should permanently fail once maximum number of failed attempts has been reached', () => { + const verificationCode = new VerificationCode(loggerMock); + + // eslint-disable-next-line dot-notation + for (let i = 0; i < verificationCode['maxFailedAttempts']; i++) { + verificationCode.verify('invalid'); + } + expect(verificationCode.verify(verificationCode.code)).toBe(false); + }); + + it('should ignore empty calls in number of failed attempts', () => { + const verificationCode = new VerificationCode(loggerMock); + + verificationCode.verify(undefined); + verificationCode.verify(undefined); + verificationCode.verify(undefined); + expect(verificationCode['failedAttempts']).toBe(0); // eslint-disable-line dot-notation + }); +}); diff --git a/src/plugins/interactive_setup/server/verification_code.ts b/src/plugins/interactive_setup/server/verification_code.ts new file mode 100644 index 0000000000000..849ece5f4e0b0 --- /dev/null +++ b/src/plugins/interactive_setup/server/verification_code.ts @@ -0,0 +1,87 @@ +/* + * 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 chalk from 'chalk'; +import crypto from 'crypto'; + +import type { Logger } from 'src/core/server'; + +import { VERIFICATION_CODE_LENGTH } from '../common'; + +export class VerificationCode { + public readonly code = VerificationCode.generate(VERIFICATION_CODE_LENGTH); + private failedAttempts = 0; + private readonly maxFailedAttempts = 5; + + constructor(private readonly logger: Logger) {} + + public get remainingAttempts() { + return this.maxFailedAttempts - this.failedAttempts; + } + + public verify(code: string | undefined) { + if (this.failedAttempts >= this.maxFailedAttempts) { + this.logger.error( + 'Maximum number of attempts exceeded. Restart Kibana to generate a new code and retry.' + ); + return false; + } + + const highlightedCode = chalk.black.bgCyanBright( + ` ${this.code.substr(0, 3)} ${this.code.substr(3)} ` + ); + + if (code === undefined) { + // eslint-disable-next-line no-console + console.log(` + +Your verification code is: ${highlightedCode} + +`); + return false; + } + + if (code !== this.code) { + this.failedAttempts++; + this.logger.error( + `Invalid verification code '${code}' provided. ${this.remainingAttempts} attempts left.` + ); + // eslint-disable-next-line no-console + console.log(` + +Your verification code is: ${highlightedCode} + +`); + return false; + } + + this.logger.debug(`Code '${code}' verified successfully`); + + this.failedAttempts = 0; + return true; + } + + /** + * Returns a cryptographically secure and random 6-digit code. + * + * Implementation notes: `secureRandomNumber` returns a random number like `0.05505769583xxxx`. To + * turn that into a 6 digit code we multiply it by `10^6` and result is `055057`. + */ + private static generate(length: number) { + return Math.floor(secureRandomNumber() * Math.pow(10, length)) + .toString() + .padStart(length, '0'); + } +} + +/** + * Cryptographically secure equivalent of `Math.random()`. + */ +function secureRandomNumber() { + return crypto.randomBytes(4).readUInt32LE() / 0x100000000; +} diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index 1f999b59ddb61..74e849948d418 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -31,6 +31,7 @@ export { UrlGeneratorsService, } from './url_generators'; +export { RedirectOptions } from './url_service'; export { useLocatorUrl } from '../common/url_service/locators/use_locator_url'; import { SharePlugin } from './plugin'; diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.ts index cc45e0d3126af..a5d895c7cbcf0 100644 --- a/src/plugins/share/public/url_service/redirect/redirect_manager.ts +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.ts @@ -15,7 +15,15 @@ import type { UrlService } from '../../../common/url_service'; import { render } from './render'; import { parseSearchParams } from './util/parse_search_params'; -export interface RedirectOptions { +/** + * @public + * Serializable locator parameters that can be used by the redirect service to navigate to a location + * in Kibana. + * + * When passed to the public {@link SharePluginSetup['navigate']} function, locator params will also be + * migrated. + */ +export interface RedirectOptions

{ /** Locator ID. */ id: string; @@ -23,7 +31,7 @@ export interface RedirectOptions { version: string; /** Locator params. */ - params: unknown & SerializableRecord; + params: P; } export interface RedirectManagerDependencies { diff --git a/src/plugins/vis_types/metric/public/components/metric_vis_component.tsx b/src/plugins/vis_types/metric/public/components/metric_vis_component.tsx index c3735bdc0d79a..837ec5ff60dc5 100644 --- a/src/plugins/vis_types/metric/public/components/metric_vis_component.tsx +++ b/src/plugins/vis_types/metric/public/components/metric_vis_component.tsx @@ -16,7 +16,7 @@ import { Datatable } from '../../../../expressions/public'; import { getHeatmapColors } from '../../../../charts/public'; import { VisParams, MetricVisMetric } from '../types'; import { getFormatService } from '../services'; -import { SchemaConfig } from '../../../../visualizations/public'; +import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { Range } from '../../../../expressions/public'; import './metric_vis.scss'; @@ -98,6 +98,16 @@ class MetricVisComponent extends Component { return fieldFormatter.convert(value, format); }; + private getColumn( + accessor: ExpressionValueVisDimension['accessor'], + columns: Datatable['columns'] = [] + ) { + if (typeof accessor === 'number') { + return columns[accessor]; + } + return columns.filter(({ id }) => accessor.id === id)[0]; + } + private processTableGroups(table: Datatable) { const config = this.props.visParams.metric; const dimensions = this.props.visParams.dimensions; @@ -112,13 +122,12 @@ class MetricVisComponent extends Component { let bucketFormatter: IFieldFormat; if (dimensions.bucket) { - bucketColumnId = table.columns[dimensions.bucket.accessor].id; + bucketColumnId = this.getColumn(dimensions.bucket.accessor, table.columns).id; bucketFormatter = getFormatService().deserialize(dimensions.bucket.format); } - dimensions.metrics.forEach((metric: SchemaConfig) => { - const columnIndex = metric.accessor; - const column = table?.columns[columnIndex]; + dimensions.metrics.forEach((metric: ExpressionValueVisDimension) => { + const column = this.getColumn(metric.accessor, table?.columns); const formatter = getFormatService().deserialize(metric.format); table.rows.forEach((row, rowIndex) => { let title = column.name; diff --git a/src/plugins/vis_types/metric/public/metric_vis_fn.ts b/src/plugins/vis_types/metric/public/metric_vis_fn.ts index 9a144defed4e7..210552732bc0a 100644 --- a/src/plugins/vis_types/metric/public/metric_vis_fn.ts +++ b/src/plugins/vis_types/metric/public/metric_vis_fn.ts @@ -15,9 +15,10 @@ import { Render, Style, } from '../../../expressions/public'; -import { visType, DimensionsVisParam, VisParams } from './types'; +import { visType, VisParams } from './types'; import { prepareLogTable, Dimension } from '../../../visualizations/public'; import { ColorSchemas, vislibColorMaps, ColorMode } from '../../../charts/public'; +import { ExpressionValueVisDimension } from '../../../visualizations/public'; export type Input = Datatable; @@ -32,8 +33,8 @@ interface Arguments { subText: string; colorRange: Range[]; font: Style; - metric: any[]; // these aren't typed yet - bucket: any; // these aren't typed yet + metric: ExpressionValueVisDimension[]; + bucket: ExpressionValueVisDimension; } export interface MetricVisRenderValue { @@ -150,14 +151,6 @@ export const createMetricVisFn = (): MetricVisExpressionFunctionDefinition => ({ }, }, fn(input, args, handlers) { - const dimensions: DimensionsVisParam = { - metrics: args.metric, - }; - - if (args.bucket) { - dimensions.bucket = args.bucket; - } - if (args.percentageMode && (!args.colorRange || args.colorRange.length === 0)) { throw new Error('colorRange must be provided when using percentageMode'); } @@ -184,6 +177,7 @@ export const createMetricVisFn = (): MetricVisExpressionFunctionDefinition => ({ const logTable = prepareLogTable(input, argsTable); handlers.inspectorAdapters.tables.logDatatable('default', logTable); } + return { type: 'render', as: 'metric_vis', @@ -209,7 +203,10 @@ export const createMetricVisFn = (): MetricVisExpressionFunctionDefinition => ({ fontSize, }, }, - dimensions, + dimensions: { + metrics: args.metric, + ...(args.bucket ? { bucket: args.bucket } : {}), + }, }, }, }; diff --git a/src/plugins/vis_types/metric/public/types.ts b/src/plugins/vis_types/metric/public/types.ts index 1baaa25959f31..8e86c0217bba6 100644 --- a/src/plugins/vis_types/metric/public/types.ts +++ b/src/plugins/vis_types/metric/public/types.ts @@ -7,14 +7,14 @@ */ import { Range } from '../../../expressions/public'; -import { SchemaConfig } from '../../../visualizations/public'; +import { ExpressionValueVisDimension } from '../../../visualizations/public'; import { ColorMode, Labels, Style, ColorSchemas } from '../../../charts/public'; export const visType = 'metric'; export interface DimensionsVisParam { - metrics: SchemaConfig[]; - bucket?: SchemaConfig; + metrics: ExpressionValueVisDimension[]; + bucket?: ExpressionValueVisDimension; } export interface MetricVisParam { diff --git a/src/plugins/vis_types/tagcloud/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/tagcloud/public/__snapshots__/to_ast.test.ts.snap index fed6fb54288f2..9e4c3071db8d6 100644 --- a/src/plugins/vis_types/tagcloud/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/tagcloud/public/__snapshots__/to_ast.test.ts.snap @@ -1,6 +1,108 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`tagcloud vis toExpressionAst function should match snapshot params fulfilled 1`] = ` +exports[`tagcloud vis toExpressionAst function should match snapshot params fulfilled with DatatableColumn vis_dimension.accessor at metric 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "aggs": Array [], + "index": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "id": Array [ + "123", + ], + }, + "function": "indexPatternLoad", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "metricsAtAllLevels": Array [ + false, + ], + "partialRows": Array [ + false, + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "bucket": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 0, + ], + "format": Array [ + "terms", + ], + "formatParams": Array [ + "{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\"}", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "maxFontSize": Array [ + 15, + ], + "metric": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 1, + ], + "format": Array [ + "number", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "minFontSize": Array [ + 5, + ], + "orientation": Array [ + "single", + ], + "palette": Array [ + "default", + ], + "scale": Array [ + "linear", + ], + "showLabel": Array [ + true, + ], + }, + "function": "tagcloud", + "type": "function", + }, + ], + "type": "expression", +} +`; + +exports[`tagcloud vis toExpressionAst function should match snapshot params fulfilled with number vis_dimension.accessor at metric 1`] = ` Object { "chain": Array [ Object { diff --git a/src/plugins/vis_types/tagcloud/public/to_ast.test.ts b/src/plugins/vis_types/tagcloud/public/to_ast.test.ts index c70448ab113cb..6de1d4fb3e75d 100644 --- a/src/plugins/vis_types/tagcloud/public/to_ast.test.ts +++ b/src/plugins/vis_types/tagcloud/public/to_ast.test.ts @@ -6,11 +6,11 @@ * Side Public License, v 1. */ -import { Vis } from 'src/plugins/visualizations/public'; +import { Vis, VisToExpressionAstParams } from '../../../visualizations/public'; import { toExpressionAst } from './to_ast'; import { TagCloudVisParams } from './types'; -const mockSchemas = { +const mockedSchemas = { metric: [{ accessor: 1, format: { id: 'number' }, params: {}, label: 'Count', aggType: 'count' }], segment: [ { @@ -31,14 +31,14 @@ const mockSchemas = { }; jest.mock('../../../visualizations/public', () => ({ - getVisSchemas: () => mockSchemas, + getVisSchemas: () => mockedSchemas, })); describe('tagcloud vis toExpressionAst function', () => { let vis: Vis; beforeEach(() => { - vis = { + vis = ({ isHierarchical: () => false, type: {}, params: { @@ -51,15 +51,15 @@ describe('tagcloud vis toExpressionAst function', () => { aggs: [], }, }, - } as any; + } as unknown) as Vis; }); it('should match snapshot without params', () => { - const actual = toExpressionAst(vis, {} as any); + const actual = toExpressionAst(vis, {} as VisToExpressionAstParams); expect(actual).toMatchSnapshot(); }); - it('should match snapshot params fulfilled', () => { + it('should match snapshot params fulfilled with number vis_dimension.accessor at metric', () => { vis.params = { scale: 'linear', orientation: 'single', @@ -70,9 +70,48 @@ describe('tagcloud vis toExpressionAst function', () => { type: 'palette', name: 'default', }, - metric: { accessor: 0, format: { id: 'number' } }, + metric: { + type: 'vis_dimension', + accessor: 0, + format: { + id: 'number', + params: { + id: 'number', + }, + }, + }, + }; + const actual = toExpressionAst(vis, {} as VisToExpressionAstParams); + expect(actual).toMatchSnapshot(); + }); + + it('should match snapshot params fulfilled with DatatableColumn vis_dimension.accessor at metric', () => { + vis.params = { + scale: 'linear', + orientation: 'single', + minFontSize: 5, + maxFontSize: 15, + showLabel: true, + palette: { + type: 'palette', + name: 'default', + }, + metric: { + type: 'vis_dimension', + accessor: { + id: 'count', + name: 'count', + meta: { type: 'number' }, + }, + format: { + id: 'number', + params: { + id: 'number', + }, + }, + }, }; - const actual = toExpressionAst(vis, {} as any); + const actual = toExpressionAst(vis, {} as VisToExpressionAstParams); expect(actual).toMatchSnapshot(); }); }); diff --git a/src/plugins/vis_types/tagcloud/public/types.ts b/src/plugins/vis_types/tagcloud/public/types.ts index 28a7c6506eb31..996555ae99f83 100644 --- a/src/plugins/vis_types/tagcloud/public/types.ts +++ b/src/plugins/vis_types/tagcloud/public/types.ts @@ -6,15 +6,7 @@ * Side Public License, v 1. */ import type { ChartsPluginSetup, PaletteOutput } from '../../../charts/public'; -import type { SerializedFieldFormat } from '../../../expressions/public'; - -interface Dimension { - accessor: number; - format: { - id?: string; - params?: SerializedFieldFormat; - }; -} +import { ExpressionValueVisDimension } from '../../../visualizations/public'; interface TagCloudCommonParams { scale: 'linear' | 'log' | 'square root'; @@ -26,8 +18,8 @@ interface TagCloudCommonParams { export interface TagCloudVisParams extends TagCloudCommonParams { palette: PaletteOutput; - metric: Dimension; - bucket?: Dimension; + metric: ExpressionValueVisDimension; + bucket?: ExpressionValueVisDimension; } export interface TagCloudTypeProps { diff --git a/src/plugins/visualizations/common/expression_functions/vis_dimension.test.ts b/src/plugins/visualizations/common/expression_functions/vis_dimension.test.ts new file mode 100644 index 0000000000000..249c796afeac3 --- /dev/null +++ b/src/plugins/visualizations/common/expression_functions/vis_dimension.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { Arguments, visDimension } from './vis_dimension'; +import { functionWrapper } from '../../../expressions/common/expression_functions/specs/tests/utils'; +import { Datatable } from '../../../expressions/common'; +import moment from 'moment'; + +describe('interpreter/functions#vis_dimension', () => { + const fn = functionWrapper(visDimension()); + const column1 = 'username'; + const column2 = '@timestamp'; + + const input: Datatable = { + type: 'datatable', + columns: [ + { id: column1, name: column1, meta: { type: 'string' } }, + { id: column2, name: column2, meta: { type: 'date' } }, + ], + rows: [ + { [column1]: 'user1', [column2]: moment().toISOString() }, + { [column1]: 'user2', [column2]: moment().toISOString() }, + ], + }; + + it('should return vis_dimension accessor in number format when type of the passed accessor is number', () => { + const accessor = 0; + const args: Arguments = { accessor }; + + const result = fn(input, args); + expect(result).toHaveProperty('type', 'vis_dimension'); + expect(result).toHaveProperty('accessor', accessor); + expect(result).toHaveProperty('format'); + expect(result.format).toBeDefined(); + expect(typeof result.format === 'object').toBeTruthy(); + }); + + it('should return vis_dimension accessor in DatatableColumn format when type of the passed accessor is string', () => { + const accessor = column2; + const args: Arguments = { accessor }; + const searchingObject = input.columns.filter(({ id }) => id === accessor)[0]; + + const result = fn(input, args); + expect(result).toHaveProperty('type', 'vis_dimension'); + expect(result).toHaveProperty('accessor'); + expect(result.accessor).toMatchObject(searchingObject); + expect(result).toHaveProperty('format'); + expect(result.format).toBeDefined(); + expect(typeof result.format === 'object').toBeTruthy(); + }); + + it('should throw error when the passed number accessor is out of columns array boundary', () => { + const accessor = input.columns.length; + const args: Arguments = { accessor }; + + expect(() => fn(input, args)).toThrowError('Column name or index provided is invalid'); + }); + + it("should throw error when the passed column doesn't exist in columns", () => { + const accessor = column1 + '_modified'; + const args: Arguments = { accessor }; + + expect(() => fn(input, args)).toThrowError('Column name or index provided is invalid'); + }); +}); diff --git a/src/plugins/visualizations/common/expression_functions/vis_dimension.ts b/src/plugins/visualizations/common/expression_functions/vis_dimension.ts index 6886fa94f878e..60d3fc78ac553 100644 --- a/src/plugins/visualizations/common/expression_functions/vis_dimension.ts +++ b/src/plugins/visualizations/common/expression_functions/vis_dimension.ts @@ -14,7 +14,7 @@ import { DatatableColumn, } from '../../../expressions/common'; -interface Arguments { +export interface Arguments { accessor: string | number; format?: string; formatParams?: string; @@ -31,6 +31,12 @@ export type ExpressionValueVisDimension = ExpressionValueBoxed< } >; +const getAccessorByIndex = (accessor: number, columns: Datatable['columns']) => + columns.length > accessor ? accessor : undefined; + +const getAccessorById = (accessor: DatatableColumn['id'], columns: Datatable['columns']) => + columns.find((c) => c.id === accessor); + export const visDimension = (): ExpressionFunctionDefinition< 'visdimension', Datatable, @@ -69,13 +75,13 @@ export const visDimension = (): ExpressionFunctionDefinition< fn: (input, args) => { const accessor = typeof args.accessor === 'number' - ? args.accessor - : input.columns.find((c) => c.id === args.accessor); + ? getAccessorByIndex(args.accessor, input.columns) + : getAccessorById(args.accessor, input.columns); if (accessor === undefined) { throw new Error( i18n.translate('visualizations.function.visDimension.error.accessor', { - defaultMessage: 'Column name provided is invalid', + defaultMessage: 'Column name or index provided is invalid', }) ); } diff --git a/src/plugins/visualizations/common/prepare_log_table.test.ts b/src/plugins/visualizations/common/prepare_log_table.test.ts index dc02adbd458ee..7176ba46c40ec 100644 --- a/src/plugins/visualizations/common/prepare_log_table.test.ts +++ b/src/plugins/visualizations/common/prepare_log_table.test.ts @@ -19,13 +19,14 @@ describe('prepareLogTable', () => { meta: {}, }, { + id: 'd3', meta: {}, }, ], }; const logTable = prepareLogTable(datatable as any, [ [[{ accessor: 0 } as any], 'dimension1'], - [[{ accessor: 2 } as any], 'dimension3'], + [[{ accessor: { id: 'd3' } } as any], 'dimension3'], [[{ accessor: 1 } as any], 'dimension2'], ]); expect(logTable).toMatchInlineSnapshot( @@ -42,6 +43,7 @@ describe('prepareLogTable', () => { }, }, { + id: 'd3', meta: { dimensionName: 'dimension3', }, @@ -62,6 +64,7 @@ describe('prepareLogTable', () => { }, }, Object { + "id": "d3", "meta": Object { "dimensionName": "dimension3", }, diff --git a/src/plugins/visualizations/common/prepare_log_table.ts b/src/plugins/visualizations/common/prepare_log_table.ts index 0018a18ce7f10..b3f74c8611af5 100644 --- a/src/plugins/visualizations/common/prepare_log_table.ts +++ b/src/plugins/visualizations/common/prepare_log_table.ts @@ -8,16 +8,31 @@ import { ExpressionValueVisDimension } from './expression_functions/vis_dimension'; import { ExpressionValueXYDimension } from './expression_functions/xy_dimension'; -import { Datatable } from '../../expressions/common/expression_types/specs'; +import { Datatable, DatatableColumn } from '../../expressions/common/expression_types/specs'; export type Dimension = [ Array | undefined, string ]; -const getDimensionName = (columnIndex: number, dimensions: Dimension[]) => { +const isColumnEqualToAccessor = ( + column: DatatableColumn, + columnIndex: number, + accessor: ExpressionValueVisDimension['accessor'] | ExpressionValueXYDimension['accessor'] +) => { + if (typeof accessor === 'number') { + return accessor === columnIndex; + } + return accessor.id === column.id; +}; + +const getDimensionName = ( + column: DatatableColumn, + columnIndex: number, + dimensions: Dimension[] +) => { for (const dimension of dimensions) { - if (dimension[0]?.find((d) => d.accessor === columnIndex)) { + if (dimension[0]?.find((d) => isColumnEqualToAccessor(column, columnIndex, d.accessor))) { return dimension[1]; } } @@ -31,7 +46,7 @@ export const prepareLogTable = (datatable: Datatable, dimensions: Dimension[]) = ...column, meta: { ...column.meta, - dimensionName: getDimensionName(columnIndex, dimensions), + dimensionName: getDimensionName(column, columnIndex, dimensions), }, }; }), diff --git a/test/functional/apps/context/_size.ts b/test/functional/apps/context/_size.ts index b11af7cd5c72f..52b16d2b9abe5 100644 --- a/test/functional/apps/context/_size.ts +++ b/test/functional/apps/context/_size.ts @@ -15,6 +15,7 @@ const TEST_STEP_SIZE = 2; export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); + const security = getService('security'); const retry = getService('retry'); const docTable = getService('docTable'); const browser = getService('browser'); @@ -23,6 +24,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('context size', function contextSize() { before(async function () { + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); await kibanaServer.uiSettings.update({ 'context:defaultSize': `${TEST_DEFAULT_CONTEXT_SIZE}`, 'context:step': `${TEST_STEP_SIZE}`, diff --git a/test/interpreter_functional/screenshots/baseline/metric_all_data.png b/test/interpreter_functional/screenshots/baseline/metric_all_data.png index 54ee1f4da6684..66357a371a5be 100644 Binary files a/test/interpreter_functional/screenshots/baseline/metric_all_data.png and b/test/interpreter_functional/screenshots/baseline/metric_all_data.png differ diff --git a/test/interpreter_functional/screenshots/baseline/metric_empty_data.png b/test/interpreter_functional/screenshots/baseline/metric_empty_data.png new file mode 100644 index 0000000000000..06cd781415ab0 Binary files /dev/null and b/test/interpreter_functional/screenshots/baseline/metric_empty_data.png differ diff --git a/test/interpreter_functional/screenshots/baseline/metric_invalid_data.png b/test/interpreter_functional/screenshots/baseline/metric_invalid_data.png index b1448cd7cb2ef..b8ffa6e8576fe 100644 Binary files a/test/interpreter_functional/screenshots/baseline/metric_invalid_data.png and b/test/interpreter_functional/screenshots/baseline/metric_invalid_data.png differ diff --git a/test/interpreter_functional/screenshots/baseline/tagcloud_empty_data.png b/test/interpreter_functional/screenshots/baseline/tagcloud_empty_data.png new file mode 100644 index 0000000000000..fa4fc01398218 Binary files /dev/null and b/test/interpreter_functional/screenshots/baseline/tagcloud_empty_data.png differ diff --git a/test/interpreter_functional/snapshots/session/metric_single_metric_data.json b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json similarity index 89% rename from test/interpreter_functional/snapshots/session/metric_single_metric_data.json rename to test/interpreter_functional/snapshots/baseline/metric_empty_data.json index f4a8cd1f14e18..c318121535c8f 100644 --- a/test/interpreter_functional/snapshots/session/metric_single_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json @@ -1 +1 @@ -{"as":"metric_vis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"colorSchema":"Green to Red","colorsRange":[{"from":0,"to":10000,"type":"range"}],"invertColors":false,"labels":{"show":true},"metricColorMode":"None","percentageMode":false,"style":{"bgColor":false,"bgFill":"#000","fontSize":60,"labelColor":false,"subText":""},"useRanges":false}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metric_vis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"colorSchema":"Green to Red","colorsRange":[{"from":0,"to":10000,"type":"range"}],"invertColors":false,"labels":{"show":true},"metricColorMode":"None","percentageMode":false,"style":{"bgColor":false,"bgFill":"#000","fontSize":60,"labelColor":false,"subText":""},"useRanges":false}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_invalid_data.json b/test/interpreter_functional/snapshots/baseline/metric_invalid_data.json index c7b4a0325dc91..f23b9b0915774 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_invalid_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_invalid_data.json @@ -1 +1 @@ -{"as":"metric_vis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"colorSchema":"Green to Red","colorsRange":[{"from":0,"to":10000,"type":"range"}],"invertColors":false,"labels":{"show":true},"metricColorMode":"None","percentageMode":false,"style":{"bgColor":false,"bgFill":"#000","fontSize":60,"labelColor":false,"subText":""},"useRanges":false}},"visData":{"columns":[],"meta":{},"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +"[metricVis] > [visdimension] > Column name or index provided is invalid" \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/partial_test_1.json b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json similarity index 65% rename from test/interpreter_functional/snapshots/session/partial_test_1.json rename to test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json index 082c7b934c17c..6dd90a4a6ca03 100644 --- a/test/interpreter_functional/snapshots/session/partial_test_1.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"default","type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"default","type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_invalid_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_invalid_data.json index 3e594380588dc..b5ae1a2cb59fc 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_invalid_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_invalid_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[],"meta":{},"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"default","type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +"[tagcloud] > [visdimension] > Column name or index provided is invalid" \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json b/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json new file mode 100644 index 0000000000000..6dd90a4a6ca03 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json @@ -0,0 +1 @@ +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"default","type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json b/test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json index 3e594380588dc..b5ae1a2cb59fc 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[],"meta":{},"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"default","type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +"[tagcloud] > [visdimension] > Column name or index provided is invalid" \ No newline at end of file diff --git a/test/interpreter_functional/test_suites/run_pipeline/metric.ts b/test/interpreter_functional/test_suites/run_pipeline/metric.ts index bbaf0486f4fbb..5483e09d6671b 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/metric.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/metric.ts @@ -30,10 +30,13 @@ export default function ({ dataContext = await expectExpression('partial_metric_test', expression).getResponse(); }); - it('with invalid data', async () => { + it('with empty data', async () => { const expression = 'metricVis metric={visdimension 0}'; await ( - await expectExpression('metric_invalid_data', expression).toMatchSnapshot() + await expectExpression('metric_empty_data', expression, { + ...dataContext, + rows: [], + }).toMatchSnapshot() ).toMatchScreenshot(); }); @@ -78,5 +81,14 @@ export default function ({ ).toMatchScreenshot(); }); }); + + describe('throws error at metric', () => { + it('with invalid data', async () => { + const expression = 'metricVis metric={visdimension 0}'; + await ( + await expectExpression('metric_invalid_data', expression).toMatchSnapshot() + ).toMatchScreenshot(); + }); + }); }); } diff --git a/test/interpreter_functional/test_suites/run_pipeline/tag_cloud.ts b/test/interpreter_functional/test_suites/run_pipeline/tag_cloud.ts index 05bbd33fedad7..3358e45dc02d4 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/tag_cloud.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/tag_cloud.ts @@ -15,24 +15,25 @@ export default function ({ }: FtrProviderContext & { updateBaselines: boolean }) { let expectExpression: ExpectExpression; describe('tag cloud pipeline expression tests', () => { - before(() => { + let dataContext: ExpressionResult; + before(async () => { expectExpression = expectExpressionProvider({ getService, updateBaselines }); + + const expression = `kibana | kibana_context | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggCount id="1" enabled=true schema="metric"} + aggs={aggTerms id="2" enabled=true schema="segment" field="response.raw" size=4 order="desc" orderBy="1"}`; + // we execute the part of expression that fetches the data and store its response + dataContext = await expectExpression('partial_tagcloud_test', expression).getResponse(); }); describe('correctly renders tagcloud', () => { - let dataContext: ExpressionResult; - before(async () => { - const expression = `kibana | kibana_context | esaggs index={indexPatternLoad id='logstash-*'} - aggs={aggCount id="1" enabled=true schema="metric"} - aggs={aggTerms id="2" enabled=true schema="segment" field="response.raw" size=4 order="desc" orderBy="1"}`; - // we execute the part of expression that fetches the data and store its response - dataContext = await expectExpression('partial_tagcloud_test', expression).getResponse(); - }); - - it('with invalid data', async () => { + it('with empty data', async () => { const expression = 'tagcloud metric={visdimension 0}'; await ( - await expectExpression('tagcloud_invalid_data', expression).toMatchSnapshot() + await expectExpression('tagcloud_empty_data', expression, { + ...dataContext, + rows: [], + }).toMatchSnapshot() ).toMatchScreenshot(); }); @@ -66,5 +67,14 @@ export default function ({ ).toMatchScreenshot(); }); }); + + describe('throws error at tagcloud', () => { + it('with invalid data', async () => { + const expression = 'tagcloud metric={visdimension 0}'; + await ( + await expectExpression('tagcloud_invalid_data', expression).toMatchSnapshot() + ).toMatchScreenshot(); + }); + }); }); } diff --git a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx index 7cf3100046d57..fa56c44d8d374 100644 --- a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx @@ -60,7 +60,7 @@ export function AlertingFlyout(props: Props) { metadata: { environment, serviceName, - transactionType, + ...(alertType === AlertType.ErrorCount ? {} : { transactionType }), start, end, } as AlertMetadata, diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index 8732084e6331e..a3820622f8c9d 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -99,14 +99,14 @@ export function getServiceColumns({ }), width: '40%', sortable: true, - render: (_, { serviceName, agentName }) => ( + render: (_, { serviceName, agentName, transactionType }) => ( } diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx index c0578514ff9ad..29bc639ee9832 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx @@ -10,6 +10,7 @@ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, + EuiLink, EuiPanel, EuiSpacer, EuiStat, @@ -24,6 +25,7 @@ import { SERVICE_NODE_NAME_MISSING, } from '../../../../common/service_nodes'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useApmParams } from '../../../hooks/use_apm_params'; @@ -33,7 +35,6 @@ import { useServiceMetricChartsFetcher } from '../../../hooks/use_service_metric import { useTimeRange } from '../../../hooks/use_time_range'; import { truncate, unit } from '../../../utils/style'; import { MetricsChart } from '../../shared/charts/metrics_chart'; -import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; const INITIAL_DATA = { host: '', @@ -99,6 +100,7 @@ export function ServiceNodeMetrics() { [kuery, serviceName, serviceNodeName, start, end] ); + const { docLinks } = useApmPluginContext().core; const isLoading = status === FETCH_STATUS.LOADING; const isAggregatedData = serviceNodeName === SERVICE_NODE_NAME_MISSING; @@ -120,16 +122,12 @@ export function ServiceNodeMetrics() { defaultMessage="We could not identify which JVMs these metrics belong to. This is likely caused by running a version of APM Server that is older than 7.5. Upgrading to APM Server 7.5 or higher should resolve this issue. For more information on upgrading, see the {link}. As an alternative, you can use the Kibana Query bar to filter by hostname, container ID or other fields." values={{ link: ( - + {i18n.translate( 'xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningDocumentationLink', { defaultMessage: 'documentation of APM Server' } )} - + ), }} /> diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 9af296e8a20b4..45372188994c7 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { useHistory } from 'react-router-dom'; import { isRumAgentName, isIosAgentName } from '../../../../common/agent_name'; import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; @@ -26,6 +27,7 @@ import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge'; import { useApmRouter } from '../../../hooks/use_apm_router'; import { useTimeRange } from '../../../hooks/use_time_range'; +import { replace } from '../../shared/Links/url_helpers'; /** * The height a chart should be if it's next to a table with 5 rows and a title. @@ -34,17 +36,29 @@ import { useTimeRange } from '../../../hooks/use_time_range'; export const chartHeight = 288; export function ServiceOverview() { - const { agentName, serviceName } = useApmServiceContext(); + const { agentName, serviceName, transactionType } = useApmServiceContext(); const { query, - query: { environment, kuery, rangeFrom, rangeTo }, + query: { + environment, + kuery, + rangeFrom, + rangeTo, + transactionType: transactionTypeFromUrl, + }, } = useApmParams('/services/:serviceName/overview'); const { fallbackToTransactions } = useFallbackToTransactionsFetcher({ kuery, }); - const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + const history = useHistory(); + + // redirect to first transaction type + if (!transactionTypeFromUrl && transactionType) { + replace(history, { query: { transactionType } }); + } + // The default EuiFlexGroup breaks at 768, but we want to break at 992, so we // observe the window width and set the flex directions of rows accordingly const { isMedium } = useBreakPoints(); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx index 06acaeeb5dd3b..ab59b60333e38 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx @@ -7,6 +7,8 @@ import { EuiSpacer, EuiTitle } from '@elastic/eui'; import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useApmParams } from '../../../hooks/use_apm_params'; @@ -15,18 +17,29 @@ import { useTimeRange } from '../../../hooks/use_time_range'; import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher'; import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge'; import { TransactionCharts } from '../../shared/charts/transaction_charts'; - +import { replace } from '../../shared/Links/url_helpers'; import { TransactionDetailsTabs } from './transaction_details_tabs'; export function TransactionDetails() { const { path, query } = useApmParams( '/services/:serviceName/transactions/view' ); - const { transactionName, rangeFrom, rangeTo } = query; - + const { + transactionName, + rangeFrom, + rangeTo, + transactionType: transactionTypeFromUrl, + } = query; const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const apmRouter = useApmRouter(); + const { transactionType } = useApmServiceContext(); + + const history = useHistory(); + + // redirect to first transaction type + if (!transactionTypeFromUrl && transactionType) { + replace(history, { query: { transactionType } }); + } useBreadcrumb({ title: transactionName, diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/Waterfall/transaction_flyout/DroppedSpansWarning.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/Waterfall/transaction_flyout/DroppedSpansWarning.tsx index 6fb1cdc45805e..2c6dbe99b6061 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/Waterfall/transaction_flyout/DroppedSpansWarning.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/Waterfall/transaction_flyout/DroppedSpansWarning.tsx @@ -5,17 +5,18 @@ * 2.0. */ -import { EuiCallOut, EuiHorizontalRule } from '@elastic/eui'; +import { EuiCallOut, EuiHorizontalRule, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; -import { ElasticDocsLink } from '../../../../../../shared/Links/ElasticDocsLink'; +import { useApmPluginContext } from '../../../../../../../context/apm_plugin/use_apm_plugin_context'; export function DroppedSpansWarning({ transactionDoc, }: { transactionDoc: Transaction; }) { + const { docLinks } = useApmPluginContext().core; const dropped = transactionDoc.transaction.span_count?.dropped; if (!dropped) { return null; @@ -32,18 +33,14 @@ export function DroppedSpansWarning({ values: { dropped }, } )}{' '} - + {i18n.translate( 'xpack.apm.transactionDetails.transFlyout.callout.learnMoreAboutDroppedSpansLinkText', { defaultMessage: 'Learn more about dropped spans.', } )} - + diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index be12522920740..571ba99d9bf08 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -5,48 +5,27 @@ * 2.0. */ -import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { Location } from 'history'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui'; import React from 'react'; -import { useLocation } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import type { ApmUrlParams } from '../../../context/url_params_context/types'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge'; import { TransactionCharts } from '../../shared/charts/transaction_charts'; -import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; +import { replace } from '../../shared/Links/url_helpers'; import { TransactionsTable } from '../../shared/transactions_table'; -import { useRedirect } from './useRedirect'; - -function getRedirectLocation({ - location, - transactionType, - urlParams, -}: { - location: Location; - transactionType?: string; - urlParams: ApmUrlParams; -}): Location | undefined { - const transactionTypeFromUrlParams = urlParams.transactionType; - - if (!transactionTypeFromUrlParams && transactionType) { - return { - ...location, - search: fromQuery({ - ...toQuery(location.search), - transactionType, - }), - }; - } -} - export function TransactionOverview() { const { - query: { environment, kuery, rangeFrom, rangeTo }, + query: { + environment, + kuery, + rangeFrom, + rangeTo, + transactionType: transactionTypeFromUrl, + }, } = useApmParams('/services/:serviceName/transactions'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -54,12 +33,14 @@ export function TransactionOverview() { const { fallbackToTransactions } = useFallbackToTransactionsFetcher({ kuery, }); - const location = useLocation(); - const { urlParams } = useUrlParams(); const { transactionType, serviceName } = useApmServiceContext(); + const history = useHistory(); + // redirect to first transaction type - useRedirect(getRedirectLocation({ location, transactionType, urlParams })); + if (!transactionTypeFromUrl && transactionType) { + replace(history, { query: { transactionType } }); + } // TODO: improve urlParams typings. // `serviceName` or `transactionType` will never be undefined here, and this check should not be needed diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/useRedirect.ts b/x-pack/plugins/apm/public/components/app/transaction_overview/useRedirect.ts deleted file mode 100644 index fae80eec42f9b..0000000000000 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/useRedirect.ts +++ /dev/null @@ -1,20 +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 { Location } from 'history'; -import { useEffect } from 'react'; -import { useHistory } from 'react-router-dom'; - -export function useRedirect(redirectLocation?: Location) { - const history = useHistory(); - - useEffect(() => { - if (redirectLocation) { - history.replace(redirectLocation); - } - }, [history, redirectLocation]); -} diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx index 938d5f4519431..45be525512d0a 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx @@ -10,6 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiLink, EuiSpacer, EuiText, EuiTitle, @@ -19,11 +20,11 @@ import { isEmpty } from 'lodash'; import React, { useCallback } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { HeightRetainer } from '../HeightRetainer'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { filterSectionsByTerm, SectionsWithRows } from './helper'; import { Section } from './Section'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; interface Props { sections: SectionsWithRows; @@ -34,6 +35,7 @@ export function MetadataTable({ sections }: Props) { const location = useLocation(); const { urlParams } = useUrlParams(); const { searchTerm = '' } = urlParams; + const { docLinks } = useApmPluginContext().core; const filteredSections = filterSectionsByTerm(sections, searchTerm); @@ -55,11 +57,11 @@ export function MetadataTable({ sections }: Props) { - + How to add labels and other data - + = (core, plu ...elementSpecs, ...initializeElementFactories.map((factory) => factory(core, plugins)), ]; - return applyElementStrings(specs); }; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/tag_cloud/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/elements/tag_cloud/index.ts new file mode 100644 index 0000000000000..a0b464390fa22 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/tag_cloud/index.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 { ElementFactory } from '../../../types'; + +export const tagCloud: ElementFactory = () => ({ + name: 'tagCloud', + displayName: 'Tag Cloud', + type: 'chart', + help: 'Tagcloud visualization', + icon: 'visTagCloud', + expression: `filters + | demodata + | head 150 + | ply by="country" expression={math "count(country)" | as "Count"} + | tagcloud metric={visdimension "Count"} bucket={visdimension "country"} + | render`, +}); diff --git a/x-pack/plugins/canvas/common/index.ts b/x-pack/plugins/canvas/common/index.ts index 51a53586dee3c..5bae69e8601b2 100644 --- a/x-pack/plugins/canvas/common/index.ts +++ b/x-pack/plugins/canvas/common/index.ts @@ -8,3 +8,5 @@ export const UI_SETTINGS = { ENABLE_LABS_UI: 'labs:canvas:enable_ui', }; + +export { CANVAS_APP_LOCATOR, CanvasAppLocator, CanvasAppLocatorParams } from './locator'; diff --git a/x-pack/plugins/canvas/common/locator.ts b/x-pack/plugins/canvas/common/locator.ts new file mode 100644 index 0000000000000..147e4fd860982 --- /dev/null +++ b/x-pack/plugins/canvas/common/locator.ts @@ -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 type { LocatorDefinition, LocatorPublic } from 'src/plugins/share/common'; + +import { CANVAS_APP } from './lib/constants'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type CanvasAppLocatorParams = { + view: 'workpadPDF'; + id: string; + page: number; +}; + +export type CanvasAppLocator = LocatorPublic; + +export const CANVAS_APP_LOCATOR = 'CANVAS_APP_LOCATOR'; + +export class CanvasAppLocatorDefinition implements LocatorDefinition { + id = CANVAS_APP_LOCATOR; + + public async getLocation(params: CanvasAppLocatorParams) { + const app = CANVAS_APP; + + if (params.view === 'workpadPDF') { + const { id, page } = params; + + return { + app, + path: `#/export/workpad/pdf/${id}/page/${page}`, + state: {}, + }; + } + + return { + app, + path: '#/', + state: {}, + }; + } +} diff --git a/x-pack/plugins/canvas/i18n/elements/element_strings.ts b/x-pack/plugins/canvas/i18n/elements/element_strings.ts index 87879c4c753c9..e1540572f4af6 100644 --- a/x-pack/plugins/canvas/i18n/elements/element_strings.ts +++ b/x-pack/plugins/canvas/i18n/elements/element_strings.ts @@ -222,4 +222,12 @@ export const getElementStrings = (): ElementStringDict => ({ defaultMessage: 'Displays progress as a portion of a vertical pill', }), }, + tagCloud: { + displayName: i18n.translate('xpack.canvas.elements.tagCloudDisplayName', { + defaultMessage: 'Tag Cloud', + }), + help: i18n.translate('xpack.canvas.elements.tagCloudHelpText', { + defaultMessage: 'Tagcloud visualization', + }), + }, }); diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index 201fb5ab8f78f..772c030e11539 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -25,7 +25,8 @@ "features", "inspector", "presentationUtil", - "uiActions" + "uiActions", + "share" ], "optionalPlugins": ["home", "reporting", "usageCollection"], "requiredBundles": [ diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx index ca8f5fd4e3e45..50a3890673ffa 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx @@ -46,9 +46,7 @@ export const ShareMenu = () => { ReportingPanelPDFComponent !== null ? ({ onClose }: { onClose: () => void }) => ( - getPdfJobParams(sharingData, platformService.getBasePathInterface()) - } + getJobParams={() => getPdfJobParams(sharingData, platformService.getKibanaVersion())} layoutOption="canvas" onClose={onClose} /> diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.test.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.test.ts index 51d1b9abc5664..18c348aec18ea 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.test.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.test.ts @@ -9,17 +9,11 @@ jest.mock('../../../../common/lib/fetch'); import { getPdfJobParams } from './utils'; import { workpads } from '../../../../__fixtures__/workpads'; -import { IBasePath } from 'kibana/public'; -const basePath = ({ - prepend: jest.fn().mockImplementation((s) => `basepath/s/spacey/${s}`), - get: () => 'basepath/s/spacey', - serverBasePath: `basepath`, -} as unknown) as IBasePath; const workpadSharingData = { workpad: workpads[0], pageCount: 12 }; test('getPdfJobParams returns the correct job params for canvas layout', () => { - const jobParams = getPdfJobParams(workpadSharingData, basePath); + const jobParams = getPdfJobParams(workpadSharingData, 'v99.99.99'); expect(jobParams).toMatchInlineSnapshot(` Object { "layout": Object { @@ -29,21 +23,117 @@ test('getPdfJobParams returns the correct job params for canvas layout', () => { }, "id": "canvas", }, - "objectType": "canvas workpad", - "relativeUrls": Array [ - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/1", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/2", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/3", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/4", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/5", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/6", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/7", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/8", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/9", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/10", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/11", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/12", + "locatorParams": Array [ + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 1, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 2, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 3, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 4, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 5, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 6, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 7, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 8, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 9, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 10, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 11, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 12, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, ], + "objectType": "canvas workpad", "title": "base workpad", } `); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts index bbd6b42a38333..311ef73e1c973 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { IBasePath } from 'kibana/public'; -import rison from 'rison-node'; +import type { RedirectOptions } from 'src/plugins/share/public'; +import { CANVAS_APP_LOCATOR } from '../../../../common/locator'; +import { CanvasAppLocatorParams } from '../../../../common/locator'; import { CanvasWorkpad } from '../../../../types'; export interface CanvasWorkpadSharingData { @@ -16,11 +17,8 @@ export interface CanvasWorkpadSharingData { export function getPdfJobParams( { workpad: { id, name: title, width, height }, pageCount }: CanvasWorkpadSharingData, - basePath: IBasePath + version: string ) { - const urlPrefix = basePath.get().replace(basePath.serverBasePath, ''); // for Spaces prefix, which is included in basePath.get() - const canvasEntry = `${urlPrefix}/app/canvas#`; - // The viewport in Reporting by specifying the dimensions. In order for things to work, // we need a viewport that will include all of the pages in the workpad. The viewport // also needs to include any offset values from the 0,0 position, otherwise the cropped @@ -32,9 +30,18 @@ export function getPdfJobParams( // viewport size. // build a list of all page urls for exporting, they are captured one at a time - const workpadUrls = []; + + const locatorParams: Array> = []; for (let i = 1; i <= pageCount; i++) { - workpadUrls.push(rison.encode(`${canvasEntry}/export/workpad/pdf/${id}/page/${i}`)); + locatorParams.push({ + id: CANVAS_APP_LOCATOR, + version, + params: { + view: 'workpadPDF', + id, + page: i, + }, + }); } return { @@ -43,7 +50,7 @@ export function getPdfJobParams( id: 'canvas', }, objectType: 'canvas workpad', - relativeUrls: workpadUrls, + locatorParams, title, }; } diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index c149c67544865..3b4a6b6f1ee4b 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -6,6 +6,7 @@ */ import { BehaviorSubject } from 'rxjs'; +import type { SharePluginSetup } from 'src/plugins/share/public'; import { ChartsPluginSetup, ChartsPluginStart } from 'src/plugins/charts/public'; import { ReportingStart } from '../../reporting/public'; import { @@ -20,7 +21,8 @@ import { import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { initLoadingIndicator } from './lib/loading_indicator'; import { getSessionStorage } from './lib/storage'; -import { SESSIONSTORAGE_LASTPATH } from '../common/lib/constants'; +import { SESSIONSTORAGE_LASTPATH, CANVAS_APP } from '../common/lib/constants'; +import { CanvasAppLocatorDefinition } from '../common/locator'; import { featureCatalogueEntry } from './feature_catalogue_entry'; import { ExpressionsSetup, ExpressionsStart } from '../../../../src/plugins/expressions/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; @@ -43,6 +45,7 @@ export { CoreStart, CoreSetup }; // This interface will be built out as we require other plugins for setup export interface CanvasSetupDeps { data: DataPublicPluginSetup; + share: SharePluginSetup; expressions: ExpressionsSetup; home?: HomePublicPluginSetup; usageCollection?: UsageCollectionSetup; @@ -97,7 +100,7 @@ export class CanvasPlugin coreSetup.application.register({ category: DEFAULT_APP_CATEGORIES.kibana, - id: 'canvas', + id: CANVAS_APP, title: 'Canvas', euiIconType: 'logoKibana', order: 3000, @@ -105,6 +108,7 @@ export class CanvasPlugin mount: async (params: AppMountParameters) => { const { CanvasSrcPlugin } = await import('../canvas_plugin_src/plugin'); const srcPlugin = new CanvasSrcPlugin(); + srcPlugin.setup(coreSetup, { canvas: canvasApi }); setupExpressions({ coreSetup, setupPlugins }); @@ -144,6 +148,10 @@ export class CanvasPlugin setupPlugins.home.featureCatalogue.register(featureCatalogueEntry); } + if (setupPlugins.share) { + setupPlugins.share.url.locators.create(new CanvasAppLocatorDefinition()); + } + canvasApi.addArgumentUIs(async () => { // @ts-expect-error const { argTypeSpecs } = await import('./expression_types/arg_types'); diff --git a/x-pack/plugins/canvas/public/services/kibana/reporting.ts b/x-pack/plugins/canvas/public/services/kibana/reporting.ts index 432fe5675b22f..02611acdea4da 100644 --- a/x-pack/plugins/canvas/public/services/kibana/reporting.ts +++ b/x-pack/plugins/canvas/public/services/kibana/reporting.ts @@ -22,7 +22,7 @@ export const reportingServiceFactory: CanvasReportingServiceFactory = ({ const { reporting } = startPlugins; const reportingEnabled = () => ({ - getReportingPanelPDFComponent: () => reporting?.components.ReportingPanelPDF || null, + getReportingPanelPDFComponent: () => reporting?.components.ReportingPanelPDFV2 || null, }); const reportingDisabled = () => ({ getReportingPanelPDFComponent: () => null }); diff --git a/x-pack/plugins/canvas/public/services/reporting.ts b/x-pack/plugins/canvas/public/services/reporting.ts index 5369dab32bf68..9ec5bd6a06a4c 100644 --- a/x-pack/plugins/canvas/public/services/reporting.ts +++ b/x-pack/plugins/canvas/public/services/reporting.ts @@ -7,7 +7,7 @@ import { ReportingStart } from '../../../reporting/public'; -type ReportingPanelPDFComponent = ReportingStart['components']['ReportingPanelPDF']; +type ReportingPanelPDFComponent = ReportingStart['components']['ReportingPanelPDFV2']; export interface CanvasReportingService { getReportingPanelPDFComponent: () => ReportingPanelPDFComponent | null; } diff --git a/x-pack/plugins/canvas/tsconfig.json b/x-pack/plugins/canvas/tsconfig.json index 87fabe2730c16..5a5a1883240b7 100644 --- a/x-pack/plugins/canvas/tsconfig.json +++ b/x-pack/plugins/canvas/tsconfig.json @@ -7,7 +7,7 @@ "declarationMap": true, // the plugin contains some heavy json files - "resolveJsonModule": false, + "resolveJsonModule": false }, "include": [ "../../../typings/**/*", @@ -20,13 +20,14 @@ "shareable_runtime/**/*", "storybook/**/*", "tasks/mocks/*", - "types/**/*", + "types/**/*" ], "references": [ { "path": "../../../src/core/tsconfig.json" }, - { "path": "../../../src/plugins/bfetch/tsconfig.json"}, + { "path": "../../../src/plugins/bfetch/tsconfig.json" }, { "path": "../../../src/plugins/charts/tsconfig.json" }, - { "path": "../../../src/plugins/data/tsconfig.json"}, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/share/tsconfig.json" }, { "path": "../../../src/plugins/discover/tsconfig.json" }, { "path": "../../../src/plugins/embeddable/tsconfig.json" }, { "path": "../../../src/plugins/expressions/tsconfig.json" }, @@ -48,6 +49,6 @@ { "path": "../features/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, { "path": "../maps/tsconfig.json" }, - { "path": "../reporting/tsconfig.json" }, + { "path": "../reporting/tsconfig.json" } ] } diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx index cda891871168e..33f6ac379cd80 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx @@ -14,6 +14,7 @@ import { ReactWrapper } from 'enzyme'; import type { CustomPaletteParams } from '../../../common'; import { applyPaletteParams } from './utils'; import { CustomizablePalette } from './palette_configuration'; +import { CUSTOM_PALETTE } from './constants'; import { act } from 'react-dom/test-utils'; // mocking random id generator function @@ -129,6 +130,21 @@ describe('palette panel', () => { }); }); + it('should restore the reverse initial state on transitioning', () => { + const instance = mountWithIntl(); + + changePaletteIn(instance, 'negative'); + + expect(props.setPalette).toHaveBeenCalledWith({ + type: 'palette', + name: 'negative', + params: expect.objectContaining({ + name: 'negative', + reverse: false, + }), + }); + }); + it('should rewrite the min/max range values on palette change', () => { const instance = mountWithIntl(); @@ -175,6 +191,20 @@ describe('palette panel', () => { }) ); }); + + it('should transition a predefined palette to a custom one on reverse click', () => { + const instance = mountWithIntl(); + + toggleReverse(instance, true); + + expect(props.setPalette).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + name: CUSTOM_PALETTE, + }), + }) + ); + }); }); describe('percentage / number modes', () => { diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx index 1d1e212b87c0c..019e83fb0aa59 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx @@ -106,6 +106,7 @@ export function CustomizablePalette({ ...activePalette.params, name: newPalette.name, colorStops: undefined, + reverse: false, // restore the reverse flag }; const newColorStops = getColorStops(palettes, [], activePalette, dataBounds); @@ -317,28 +318,20 @@ export function CustomizablePalette({ className="lnsPalettePanel__reverseButton" data-test-subj="lnsPalettePanel_dynamicColoring_reverse" onClick={() => { - const params: CustomPaletteParams = { reverse: !activePalette.params?.reverse }; - if (isCurrentPaletteCustom) { - params.colorStops = reversePalette(colorStopsToShow); - params.stops = getPaletteStops( - palettes, - { - ...(activePalette?.params || {}), - colorStops: params.colorStops, - }, - { dataBounds } - ); - } else { - params.stops = reversePalette( - activePalette?.params?.stops || - getPaletteStops( - palettes, - { ...activePalette.params, ...params }, - { prevPalette: activePalette.name, dataBounds } - ) - ); - } - setPalette(mergePaletteParams(activePalette, params)); + // when reversing a palette, the palette is automatically transitioned to a custom palette + const newParams = getSwitchToCustomParams( + palettes, + activePalette, + { + colorStops: reversePalette(colorStopsToShow), + steps: activePalette.params?.steps || DEFAULT_COLOR_STEPS, + reverse: !activePalette.params?.reverse, // Store the reverse state + rangeMin: colorStopsToShow[0]?.stop, + rangeMax: colorStopsToShow[colorStopsToShow.length - 1]?.stop, + }, + dataBounds + ); + setPalette(newParams); }} > diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx index 2a415cd178925..b21b732820eaa 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx @@ -83,7 +83,7 @@ export function PalettePicker({ value: id, title, type: FIXED_PROGRESSION, - palette: activePalette?.params?.reverse ? colors.reverse() : colors, + palette: colors, 'data-test-subj': `${id}-palette`, }; }); diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts index 86a3a600b58ab..4423d9e659119 100644 --- a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts @@ -9,6 +9,7 @@ import { EmbeddableRegistryDefinition } from 'src/plugins/embeddable/server'; import type { SerializableRecord } from '@kbn/utility-types'; import { DOC_TYPE } from '../../common'; import { + commonMakeReversePaletteAsCustom, commonRemoveTimezoneDateHistogramParam, commonRenameOperationsForFormula, commonUpdateVisLayerType, @@ -17,6 +18,7 @@ import { LensDocShape713, LensDocShape715, LensDocShapePre712, + VisState716, VisStatePre715, } from '../migrations/types'; import { extract, inject } from '../../common/embeddable_factory'; @@ -50,6 +52,14 @@ export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { attributes: migratedLensState, } as unknown) as SerializableRecord; }, + '7.16.0': (state) => { + const lensState = (state as unknown) as { attributes: LensDocShape715 }; + const migratedLensState = commonMakeReversePaletteAsCustom(lensState.attributes); + return ({ + ...lensState, + attributes: migratedLensState, + } as unknown) as SerializableRecord; + }, }, extract, inject, diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index fda4300e03ea9..5755416957440 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -6,6 +6,7 @@ */ import { cloneDeep } from 'lodash'; +import { PaletteOutput } from 'src/plugins/charts/common'; import { LensDocShapePre712, OperationTypePre712, @@ -15,8 +16,9 @@ import { LensDocShape715, VisStatePost715, VisStatePre715, + VisState716, } from './types'; -import { layerTypes } from '../../common'; +import { CustomPaletteParams, layerTypes } from '../../common'; export const commonRenameOperationsForFormula = ( attributes: LensDocShapePre712 @@ -98,3 +100,56 @@ export const commonUpdateVisLayerType = ( } return newAttributes as LensDocShape715; }; + +function moveDefaultPaletteToPercentCustomInPlace(palette?: PaletteOutput) { + if (palette?.params?.reverse && palette.params.name !== 'custom' && palette.params.stops) { + // change to palette type to custom and migrate to a percentage type of mode + palette.name = 'custom'; + palette.params.name = 'custom'; + // we can make strong assumptions here: + // because it was a default palette reversed it means that stops were the default ones + // so when migrating, because there's no access to active data, we could leverage the + // percent rangeType to define colorStops in percent. + // + // Stops should be defined, but reversed, as the previous code was rewriting them on reverse. + // + // The only change the user should notice should be the mode changing from number to percent + // but the final result *must* be identical + palette.params.rangeType = 'percent'; + const steps = palette.params.stops.length; + palette.params.rangeMin = 0; + palette.params.rangeMax = 80; + palette.params.steps = steps; + palette.params.colorStops = palette.params.stops.map(({ color }, index) => ({ + color, + stop: (index * 100) / steps, + })); + palette.params.stops = palette.params.stops.map(({ color }, index) => ({ + color, + stop: ((1 + index) * 100) / steps, + })); + } +} + +export const commonMakeReversePaletteAsCustom = ( + attributes: LensDocShape715 +): LensDocShape715 => { + const newAttributes = cloneDeep(attributes); + const vizState = (newAttributes as LensDocShape715).state.visualization; + if ( + attributes.visualizationType !== 'lnsDatatable' && + attributes.visualizationType !== 'lnsHeatmap' + ) { + return newAttributes; + } + if ('columns' in vizState) { + for (const column of vizState.columns) { + if (column.colorMode && column.colorMode !== 'none') { + moveDefaultPaletteToPercentCustomInPlace(column.palette); + } + } + } else { + moveDefaultPaletteToPercentCustomInPlace(vizState.palette); + } + return newAttributes; +}; diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index afc6e6c6a590c..c16c5b5169ac5 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -12,8 +12,9 @@ import { SavedObjectMigrationFn, SavedObjectUnsanitizedDoc, } from 'src/core/server'; -import { LensDocShape715, VisStatePost715, VisStatePre715 } from './types'; -import { layerTypes } from '../../common'; +import { LensDocShape715, VisState716, VisStatePost715, VisStatePre715 } from './types'; +import { CustomPaletteParams, layerTypes } from '../../common'; +import { PaletteOutput } from 'src/plugins/charts/common'; describe('Lens migrations', () => { describe('7.7.0 missing dimensions in XY', () => { @@ -1129,4 +1130,276 @@ describe('Lens migrations', () => { } }); }); + + describe('7.16.0 move reversed default palette to custom palette', () => { + const context = ({ log: { warning: () => {} } } as unknown) as SavedObjectMigrationContext; + const example = ({ + type: 'lens', + id: 'mocked-saved-object-id', + attributes: { + savedObjectId: '1', + title: 'MyRenamedOps', + description: '', + visualizationType: null, + state: { + datasourceMetaData: { + filterableIndexPatterns: [], + }, + datasourceStates: { + indexpattern: { + currentIndexPatternId: 'logstash-*', + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto', timeZone: 'Europe/Berlin' }, + }, + '4': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5': { + label: '@timestamp', + dataType: 'date', + operationType: 'my_unexpected_operation', + isBucketed: true, + scale: 'interval', + params: { timeZone: 'do not delete' }, + }, + }, + columnOrder: ['3', '4', '5'], + }, + }, + }, + }, + visualization: {}, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }, + } as unknown) as SavedObjectUnsanitizedDoc>; + + it('should just return the same document for XY, partition and metric visualization types', () => { + for (const vizType of ['lnsXY', 'lnsPie', 'lnsMetric']) { + const exampleCopy = cloneDeep(example); + exampleCopy.attributes.visualizationType = vizType; + // add datatable state here, even with another viz (manual change?) + (exampleCopy.attributes as LensDocShape715).state.visualization = ({ + columns: [ + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'cell' }, + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'text' }, + { + palette: { type: 'palette', name: 'temperature', params: { reverse: false } }, + colorMode: 'cell', + }, + ], + } as unknown) as VisState716; + const result = migrations['7.16.0'](exampleCopy, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toEqual(exampleCopy); + } + }); + + it('should not change non reversed default palettes in datatable', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + columns: [ + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'cell' }, + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'text' }, + { + palette: { type: 'palette', name: 'temperature', params: { reverse: false } }, + colorMode: 'cell', + }, + ], + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toEqual(datatableExample); + }); + + it('should not change custom palettes in datatable', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + columns: [ + { palette: { type: 'palette', name: 'custom' }, colorMode: 'cell' }, + { palette: { type: 'palette', name: 'custom' }, colorMode: 'text' }, + { + palette: { type: 'palette', name: 'custom', params: { reverse: true } }, + colorMode: 'cell', + }, + ], + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toEqual(datatableExample); + }); + + it('should not change a datatable with no conditional coloring', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + columns: [{ colorMode: 'none' }, {}], + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toEqual(datatableExample); + }); + + it('should not change default palette if the colorMode is set to "none" in datatable', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + columns: [ + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'none' }, + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'none' }, + { + palette: { type: 'palette', name: 'temperature', params: { reverse: true } }, + colorMode: 'cell', + }, + ], + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toEqual(datatableExample); + }); + + it('should change a default palette reversed in datatable', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + columns: [ + { + colorMode: 'cell', + palette: { + type: 'palette', + name: 'temperature1', + params: { + reverse: true, + rangeType: 'number', + stops: [ + { color: 'red', stop: 10 }, + { color: 'blue', stop: 20 }, + { color: 'pink', stop: 50 }, + { color: 'green', stop: 60 }, + { color: 'yellow', stop: 70 }, + ], + }, + }, + }, + { + colorMode: 'text', + palette: { + type: 'palette', + name: 'temperature2', + params: { + reverse: true, + rangeType: 'number', + stops: [ + { color: 'red', stop: 10 }, + { color: 'blue', stop: 20 }, + { color: 'pink', stop: 50 }, + { color: 'green', stop: 60 }, + { color: 'yellow', stop: 70 }, + ], + }, + }, + }, + ], + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715< + Extract + >).state.visualization; + for (const column of state.columns) { + expect(column.palette!.name).toBe('custom'); + expect(column.palette!.params!.name).toBe('custom'); + expect(column.palette!.params!.rangeMin).toBe(0); + expect(column.palette!.params!.rangeMax).toBe(80); + expect(column.palette!.params!.reverse).toBeTruthy(); // still true + expect(column.palette!.params!.rangeType).toBe('percent'); + expect(column.palette!.params!.stops).toEqual([ + { color: 'red', stop: 20 }, + { color: 'blue', stop: 40 }, + { color: 'pink', stop: 60 }, + { color: 'green', stop: 80 }, + { color: 'yellow', stop: 100 }, + ]); + expect(column.palette!.params!.colorStops).toEqual([ + { color: 'red', stop: 0 }, + { color: 'blue', stop: 20 }, + { color: 'pink', stop: 40 }, + { color: 'green', stop: 60 }, + { color: 'yellow', stop: 80 }, + ]); + } + }); + + it('should change a default palette reversed in heatmap', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsHeatmap'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + palette: { + type: 'palette', + name: 'temperature1', + params: { + reverse: true, + rangeType: 'number', + stops: [ + { color: 'red', stop: 10 }, + { color: 'blue', stop: 20 }, + { color: 'pink', stop: 50 }, + { color: 'green', stop: 60 }, + { color: 'yellow', stop: 70 }, + ], + }, + }, + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715< + Extract }> + >).state.visualization; + expect(state.palette!.name).toBe('custom'); + expect(state.palette!.params!.name).toBe('custom'); + expect(state.palette!.params!.rangeMin).toBe(0); + expect(state.palette!.params!.rangeMax).toBe(80); + expect(state.palette!.params!.reverse).toBeTruthy(); // still true + expect(state.palette!.params!.rangeType).toBe('percent'); + expect(state.palette!.params!.stops).toEqual([ + { color: 'red', stop: 20 }, + { color: 'blue', stop: 40 }, + { color: 'pink', stop: 60 }, + { color: 'green', stop: 80 }, + { color: 'yellow', stop: 100 }, + ]); + expect(state.palette!.params!.colorStops).toEqual([ + { color: 'red', stop: 0 }, + { color: 'blue', stop: 20 }, + { color: 'pink', stop: 40 }, + { color: 'green', stop: 60 }, + { color: 'yellow', stop: 80 }, + ]); + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index 7d08e76841cf5..901f0b5d6e684 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -23,11 +23,13 @@ import { LensDocShape715, VisStatePost715, VisStatePre715, + VisState716, } from './types'; import { commonRenameOperationsForFormula, commonRemoveTimezoneDateHistogramParam, commonUpdateVisLayerType, + commonMakeReversePaletteAsCustom, } from './common_migrations'; interface LensDocShapePre710 { @@ -430,6 +432,14 @@ const addLayerTypeToVisualization: SavedObjectMigrationFn< return { ...newDoc, attributes: commonUpdateVisLayerType(newDoc.attributes) }; }; +const moveDefaultReversedPaletteToCustom: SavedObjectMigrationFn< + LensDocShape715, + LensDocShape715 +> = (doc) => { + const newDoc = cloneDeep(doc); + return { ...newDoc, attributes: commonMakeReversePaletteAsCustom(newDoc.attributes) }; +}; + export const migrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -442,4 +452,5 @@ export const migrations: SavedObjectMigrationMap = { '7.13.1': renameOperationsForFormula, // duplicate this migration in case a broken by value panel is added to the library '7.14.0': removeTimezoneDateHistogramParam, '7.15.0': addLayerTypeToVisualization, + '7.16.0': moveDefaultReversedPaletteToCustom, }; diff --git a/x-pack/plugins/lens/server/migrations/types.ts b/x-pack/plugins/lens/server/migrations/types.ts index 09b460ff8b8cd..2e6e66aed9949 100644 --- a/x-pack/plugins/lens/server/migrations/types.ts +++ b/x-pack/plugins/lens/server/migrations/types.ts @@ -5,8 +5,9 @@ * 2.0. */ +import type { PaletteOutput } from 'src/plugins/charts/common'; import { Query, Filter } from 'src/plugins/data/public'; -import type { LayerType } from '../../common'; +import type { CustomPaletteParams, LayerType } from '../../common'; export type OperationTypePre712 = | 'avg' @@ -192,3 +193,16 @@ export interface LensDocShape715 { filters: Filter[]; }; } + +export type VisState716 = + // Datatable + | { + columns: Array<{ + palette?: PaletteOutput; + colorMode?: 'none' | 'cell' | 'text'; + }>; + } + // Heatmap + | { + palette?: PaletteOutput; + }; diff --git a/x-pack/plugins/osquery/public/action_results/use_action_privileges.tsx b/x-pack/plugins/osquery/public/action_results/use_action_privileges.tsx index 2c80c874e89fa..6d0477b22edee 100644 --- a/x-pack/plugins/osquery/public/action_results/use_action_privileges.tsx +++ b/x-pack/plugins/osquery/public/action_results/use_action_privileges.tsx @@ -6,28 +6,16 @@ */ import { useQuery } from 'react-query'; - -import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; -import { useErrorToast } from '../common/hooks/use_error_toast'; export const useActionResultsPrivileges = () => { const { http } = useKibana().services; - const setErrorToast = useErrorToast(); return useQuery( ['actionResultsPrivileges'], () => http.get('/internal/osquery/privileges_check'), { keepPreviousData: true, - select: (response) => response?.has_all_requested ?? false, - onSuccess: () => setErrorToast(), - onError: (error: Error) => - setErrorToast(error, { - title: i18n.translate('xpack.osquery.action_results_privileges.fetchError', { - defaultMessage: 'Error while fetching action results privileges', - }), - }), } ); }; diff --git a/x-pack/plugins/osquery/public/common/page_paths.ts b/x-pack/plugins/osquery/public/common/page_paths.ts index 0e0d8310ae8be..8df1006da181a 100644 --- a/x-pack/plugins/osquery/public/common/page_paths.ts +++ b/x-pack/plugins/osquery/public/common/page_paths.ts @@ -27,8 +27,6 @@ export interface DynamicPagePathValues { [key: string]: string; } -export const BASE_PATH = '/app/fleet'; - // If routing paths are changed here, please also check to see if // `pagePathGetters()`, below, needs any modifications export const PAGE_ROUTING_PATHS = { diff --git a/x-pack/plugins/osquery/public/components/app.tsx b/x-pack/plugins/osquery/public/components/app.tsx index 44407139ab492..33fb6ac6a2adf 100644 --- a/x-pack/plugins/osquery/public/components/app.tsx +++ b/x-pack/plugins/osquery/public/components/app.tsx @@ -5,6 +5,8 @@ * 2.0. */ +/* eslint-disable react-hooks/rules-of-hooks */ + import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiTabs, EuiTab } from '@elastic/eui'; @@ -14,10 +16,17 @@ import { Container, Nav, Wrapper } from './layouts'; import { OsqueryAppRoutes } from '../routes'; import { useRouterNavigate } from '../common/lib/kibana'; import { ManageIntegrationLink } from './manage_integration_link'; +import { useOsqueryIntegrationStatus } from '../common/hooks'; +import { OsqueryAppEmptyState } from './empty_state'; const OsqueryAppComponent = () => { const location = useLocation(); const section = useMemo(() => location.pathname.split('/')[1] ?? 'overview', [location.pathname]); + const { data: osqueryIntegration, isFetched } = useOsqueryIntegrationStatus(); + + if (isFetched && osqueryIntegration.install_status !== 'installed') { + return ; + } return ( diff --git a/x-pack/plugins/osquery/public/components/empty_state.tsx b/x-pack/plugins/osquery/public/components/empty_state.tsx new file mode 100644 index 0000000000000..1ee0d496c0ddc --- /dev/null +++ b/x-pack/plugins/osquery/public/components/empty_state.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton } from '@elastic/eui'; + +import { KibanaPageTemplate } from '../../../../../src/plugins/kibana_react/public'; +import { INTEGRATIONS_PLUGIN_ID } from '../../../fleet/common'; +import { pagePathGetters } from '../../../fleet/public'; +import { isModifiedEvent, isLeftClickEvent, useKibana } from '../common/lib/kibana'; +import { OsqueryIcon } from './osquery_icon'; +import { useBreadcrumbs } from '../common/hooks/use_breadcrumbs'; +import { OSQUERY_INTEGRATION_NAME } from '../../common'; + +const OsqueryAppEmptyStateComponent = () => { + useBreadcrumbs('base'); + + const { + application: { getUrlForApp, navigateToApp }, + } = useKibana().services; + + const integrationHref = useMemo(() => { + return getUrlForApp(INTEGRATIONS_PLUGIN_ID, { + path: pagePathGetters.integration_details_overview({ + pkgkey: OSQUERY_INTEGRATION_NAME, + })[1], + }); + }, [getUrlForApp]); + + const integrationClick = useCallback( + (event) => { + if (!isModifiedEvent(event) && isLeftClickEvent(event)) { + event.preventDefault(); + return navigateToApp(INTEGRATIONS_PLUGIN_ID, { + path: pagePathGetters.integration_details_overview({ + pkgkey: OSQUERY_INTEGRATION_NAME, + })[1], + }); + } + }, + [navigateToApp] + ); + + const pageHeader = useMemo( + () => ({ + iconType: OsqueryIcon, + pageTitle: ( + + ), + description: ( + + ), + rightSideItems: [ + // eslint-disable-next-line @elastic/eui/href-or-on-click + + + , + ], + }), + [integrationClick, integrationHref] + ); + + return ; +}; + +export const OsqueryAppEmptyState = React.memo(OsqueryAppEmptyStateComponent); diff --git a/x-pack/plugins/osquery/public/components/manage_integration_link.tsx b/x-pack/plugins/osquery/public/components/manage_integration_link.tsx index 44b923860e1a8..32779ded46c50 100644 --- a/x-pack/plugins/osquery/public/components/manage_integration_link.tsx +++ b/x-pack/plugins/osquery/public/components/manage_integration_link.tsx @@ -24,11 +24,9 @@ const ManageIntegrationLinkComponent = () => { const integrationHref = useMemo(() => { if (osqueryIntegration) { return getUrlForApp(INTEGRATIONS_PLUGIN_ID, { - path: - '#' + - pagePathGetters.integration_details_policies({ - pkgkey: `${osqueryIntegration.name}-${osqueryIntegration.version}`, - })[1], + path: pagePathGetters.integration_details_policies({ + pkgkey: `${osqueryIntegration.name}-${osqueryIntegration.version}`, + })[1], }); } }, [getUrlForApp, osqueryIntegration]); @@ -39,11 +37,9 @@ const ManageIntegrationLinkComponent = () => { event.preventDefault(); if (osqueryIntegration) { return navigateToApp(INTEGRATIONS_PLUGIN_ID, { - path: - '#' + - pagePathGetters.integration_details_policies({ - pkgkey: `${osqueryIntegration.name}-${osqueryIntegration.version}`, - })[1], + path: pagePathGetters.integration_details_policies({ + pkgkey: `${osqueryIntegration.name}-${osqueryIntegration.version}`, + })[1], }); } } diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index 987be904c87e6..69b02dee8b9f7 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -114,7 +114,7 @@ const LiveQueryFormComponent: React.FC = ({ ), }); - const { setFieldValue, submit } = form; + const { setFieldValue, submit, isSubmitting } = form; const actionId = useMemo(() => data?.actions[0].action_id, [data?.actions]); const agentIds = useMemo(() => data?.actions[0].agents, [data?.actions]); @@ -185,7 +185,10 @@ const LiveQueryFormComponent: React.FC = ({ )} - + = ({ ), [ - agentSelected, - permissions.writeSavedQueries, - handleShowSaveQueryFlout, queryComponentProps, + singleAgentMode, + permissions.writeSavedQueries, + agentSelected, queryValueProvided, resultsStatus, - singleAgentMode, + handleShowSaveQueryFlout, + isSubmitting, submit, ] ); diff --git a/x-pack/plugins/osquery/public/packs/common/add_pack_query.tsx b/x-pack/plugins/osquery/public/packs/common/add_pack_query.tsx index 2d58e2dfe9522..d1115898b4e40 100644 --- a/x-pack/plugins/osquery/public/packs/common/add_pack_query.tsx +++ b/x-pack/plugins/osquery/public/packs/common/add_pack_query.tsx @@ -51,7 +51,7 @@ const AddPackQueryFormComponent = ({ handleSubmit }) => { }, }, }); - const { submit } = form; + const { submit, isSubmitting } = form; const createSavedQueryMutation = useMutation( (payload) => http.post(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }), @@ -108,7 +108,7 @@ const AddPackQueryFormComponent = ({ handleSubmit }) => { - + {'Add query'} diff --git a/x-pack/plugins/osquery/public/packs/common/pack_form.tsx b/x-pack/plugins/osquery/public/packs/common/pack_form.tsx index 86d4d8dff6ba6..ab0984e808943 100644 --- a/x-pack/plugins/osquery/public/packs/common/pack_form.tsx +++ b/x-pack/plugins/osquery/public/packs/common/pack_form.tsx @@ -40,7 +40,7 @@ const PackFormComponent = ({ data, handleSubmit }) => { }, }, }); - const { submit } = form; + const { submit, isSubmitting } = form; return (
@@ -50,7 +50,7 @@ const PackFormComponent = ({ data, handleSubmit }) => { - + {'Save pack'} diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/edit/form.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/edit/form.tsx index a7596575b90c4..617d83821d08d 100644 --- a/x-pack/plugins/osquery/public/routes/saved_queries/edit/form.tsx +++ b/x-pack/plugins/osquery/public/routes/saved_queries/edit/form.tsx @@ -38,6 +38,7 @@ const EditSavedQueryFormComponent: React.FC = ({ defaultValue, handleSubmit, }); + const { submit, isSubmitting } = form; return (
@@ -58,12 +59,12 @@ const EditSavedQueryFormComponent: React.FC = ({ = ({ defaultValue, handleSubmit, }); + const { submit, isSubmitting } = form; return ( @@ -54,12 +55,12 @@ const NewSavedQueryFormComponent: React.FC = ({ { const { data } = useScheduledQueryGroup({ scheduledQueryGroupId }); - useBreadcrumbs('scheduled_query_group_edit', { scheduledQueryGroupName: data?.name ?? '' }); + useBreadcrumbs('scheduled_query_group_edit', { + scheduledQueryGroupId: data?.id ?? '', + scheduledQueryGroupName: data?.name ?? '', + }); const LeftColumn = useMemo( () => ( diff --git a/x-pack/plugins/osquery/public/saved_queries/constants.ts b/x-pack/plugins/osquery/public/saved_queries/constants.ts index 69ca805e3e8fa..8edcfd00d1788 100644 --- a/x-pack/plugins/osquery/public/saved_queries/constants.ts +++ b/x-pack/plugins/osquery/public/saved_queries/constants.ts @@ -6,3 +6,4 @@ */ export const SAVED_QUERIES_ID = 'savedQueryList'; +export const SAVED_QUERY_ID = 'savedQuery'; diff --git a/x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx b/x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx index 6d14943a6bc84..8c35a359a9baf 100644 --- a/x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx @@ -42,6 +42,7 @@ const SavedQueryFlyoutComponent: React.FC = ({ defaultValue defaultValue, handleSubmit, }); + const { submit, isSubmitting } = form; return ( @@ -72,7 +73,7 @@ const SavedQueryFlyoutComponent: React.FC = ({ defaultValue - + { queryClient.invalidateQueries(SAVED_QUERIES_ID); + queryClient.invalidateQueries([SAVED_QUERY_ID, { savedQueryId }]); navigateToApp(PLUGIN_ID, { path: pagePathGetters.saved_queries() }); toasts.addSuccess( i18n.translate('xpack.osquery.editSavedQuery.successToastMessageText', { diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx index 685960ecd202e..3598a9fd2e44c 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx @@ -88,7 +88,7 @@ const ScheduledQueryGroupFormComponent: React.FC = `scheduled_query_groups/${editMode ? defaultValue?.id : ''}` ); - const { isLoading, mutateAsync } = useMutation( + const { mutateAsync } = useMutation( (payload: Record) => editMode && defaultValue?.id ? http.put(packagePolicyRouteService.getUpdatePath(defaultValue.id), { @@ -248,7 +248,7 @@ const ScheduledQueryGroupFormComponent: React.FC = ), }); - const { setFieldValue, submit } = form; + const { setFieldValue, submit, isSubmitting } = form; const policyIdEuiFieldProps = useMemo( () => ({ isDisabled: !!defaultValue, options: agentPolicyOptions }), @@ -368,7 +368,7 @@ const ScheduledQueryGroupFormComponent: React.FC = ( ) )(args); - if (fieldRequiredError && (!!(!editForm && args.formData.value?.field.length) || editForm)) { + // @ts-expect-error update types + if (fieldRequiredError && ((!editForm && args.formData['value.field'].length) || editForm)) { return fieldRequiredError; } diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx index cae9711694f29..d38c1b2118f24 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { isEmpty } from 'lodash'; import { EuiCallOut, EuiFlyout, @@ -66,7 +67,7 @@ const QueryFlyoutComponent: React.FC = ({ if (isValid && ecsFieldValue) { onSave({ ...payload, - ecs_mapping: ecsFieldValue, + ...(isEmpty(ecsFieldValue) ? {} : { ecs_mapping: ecsFieldValue }), }); onClose(); } @@ -81,7 +82,7 @@ const QueryFlyoutComponent: React.FC = ({ [integrationPackageVersion] ); - const { submit, setFieldValue, reset } = form; + const { submit, setFieldValue, reset, isSubmitting } = form; const [{ query }] = useFormData({ form, @@ -245,7 +246,7 @@ const QueryFlyoutComponent: React.FC = ({ - + = ({ toggleErrors, expanded, }) => { + const data = useKibana().services.data; + const [logsIndexPattern, setLogsIndexPattern] = useState(undefined); + const { data: lastResultsData, isFetched } = useScheduledQueryGroupQueryLastResults({ actionId, agentIds, interval, + logsIndexPattern, }); const { data: errorsData, isFetched: errorsFetched } = useScheduledQueryGroupQueryErrors({ actionId, agentIds, interval, + logsIndexPattern, }); const handleErrorsToggle = useCallback(() => toggleErrors({ queryId, interval }), [ @@ -409,20 +414,41 @@ const ScheduledQueryLastResults: React.FC = ({ toggleErrors, ]); + useEffect(() => { + const fetchLogsIndexPattern = async () => { + const indexPattern = await data.indexPatterns.find('logs-*'); + + setLogsIndexPattern(indexPattern[0]); + }; + fetchLogsIndexPattern(); + }, [data.indexPatterns]); + if (!isFetched || !errorsFetched) { return ; } - if (!lastResultsData) { + if (!lastResultsData && !errorsData?.total) { return <>{'-'}; } return ( - {lastResultsData.first_event_ingested_time?.value ? ( - - <>{moment(lastResultsData.first_event_ingested_time?.value).fromNow()} + {lastResultsData?.['@timestamp'] ? ( + + {' '} + + + } + > + ) : ( '-' @@ -432,10 +458,17 @@ const ScheduledQueryLastResults: React.FC = ({ - {lastResultsData?.doc_count ?? 0} + {lastResultsData?.docCount ?? 0} - {'Documents'} + + + @@ -443,10 +476,17 @@ const ScheduledQueryLastResults: React.FC = ({ - {lastResultsData?.unique_agents?.value ?? 0} + {lastResultsData?.uniqueAgentsCount ?? 0} - {'Agents'} + + + @@ -458,7 +498,15 @@ const ScheduledQueryLastResults: React.FC = ({ - {'Errors'} + + {' '} + + { const data = useKibana().services.data; @@ -28,9 +30,8 @@ export const useScheduledQueryGroupQueryErrors = ({ return useQuery( ['scheduledQueryErrors', { actionId, interval }], async () => { - const indexPattern = await data.indexPatterns.find('logs-*'); const searchSource = await data.search.searchSource.create({ - index: indexPattern[0], + index: logsIndexPattern, fields: ['*'], sort: [ { @@ -80,7 +81,7 @@ export const useScheduledQueryGroupQueryErrors = ({ }, { keepPreviousData: true, - enabled: !!(!skip && actionId && interval && agentIds?.length), + enabled: !!(!skip && actionId && interval && agentIds?.length && logsIndexPattern), select: (response) => response.rawResponse.hits ?? [], refetchOnReconnect: false, refetchOnWindowFocus: false, diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group_query_last_results.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group_query_last_results.ts index f972640e25986..7cfd6be461e05 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group_query_last_results.ts +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group_query_last_results.ts @@ -6,13 +6,14 @@ */ import { useQuery } from 'react-query'; - +import { IndexPattern } from '../../../../../src/plugins/data/common'; import { useKibana } from '../common/lib/kibana'; interface UseScheduledQueryGroupQueryLastResultsProps { actionId: string; agentIds?: string[]; interval: number; + logsIndexPattern?: IndexPattern; skip?: boolean; } @@ -20,6 +21,7 @@ export const useScheduledQueryGroupQueryLastResults = ({ actionId, agentIds, interval, + logsIndexPattern, skip = false, }: UseScheduledQueryGroupQueryLastResultsProps) => { const data = useKibana().services.data; @@ -27,23 +29,9 @@ export const useScheduledQueryGroupQueryLastResults = ({ return useQuery( ['scheduledQueryLastResults', { actionId }], async () => { - const indexPattern = await data.indexPatterns.find('logs-*'); - const searchSource = await data.search.searchSource.create({ - index: indexPattern[0], - size: 0, - aggs: { - runs: { - terms: { - field: 'response_id', - order: { first_event_ingested_time: 'desc' }, - size: 1, - }, - aggs: { - first_event_ingested_time: { min: { field: '@timestamp' } }, - unique_agents: { cardinality: { field: 'agent.id' } }, - }, - }, - }, + const lastResultsSearchSource = await data.search.searchSource.create({ + index: logsIndexPattern, + size: 1, query: { // @ts-expect-error update types bool: { @@ -59,26 +47,62 @@ export const useScheduledQueryGroupQueryLastResults = ({ action_id: actionId, }, }, - { - range: { - '@timestamp': { - gte: `now-${interval * 2}s`, - lte: 'now', - }, - }, - }, ], }, }, }); - return searchSource.fetch$().toPromise(); + const lastResultsResponse = await lastResultsSearchSource.fetch$().toPromise(); + + const responseId = lastResultsResponse.rawResponse?.hits?.hits[0]?._source?.response_id; + + if (responseId) { + const aggsSearchSource = await data.search.searchSource.create({ + index: logsIndexPattern, + size: 0, + aggs: { + unique_agents: { cardinality: { field: 'agent.id' } }, + }, + query: { + // @ts-expect-error update types + bool: { + should: agentIds?.map((agentId) => ({ + match_phrase: { + 'agent.id': agentId, + }, + })), + minimum_should_match: 1, + filter: [ + { + match_phrase: { + action_id: actionId, + }, + }, + { + match_phrase: { + response_id: responseId, + }, + }, + ], + }, + }, + }); + + const aggsResponse = await aggsSearchSource.fetch$().toPromise(); + + return { + '@timestamp': lastResultsResponse.rawResponse?.hits?.hits[0]?.fields?.['@timestamp'], + // @ts-expect-error update types + uniqueAgentsCount: aggsResponse.rawResponse.aggregations?.unique_agents?.value, + docCount: aggsResponse.rawResponse?.hits?.total, + }; + } + + return null; }, { keepPreviousData: true, - enabled: !!(!skip && actionId && interval && agentIds?.length), - // @ts-expect-error update types - select: (response) => response.rawResponse.aggregations?.runs?.buckets[0] ?? [], + enabled: !!(!skip && actionId && interval && agentIds?.length && logsIndexPattern), refetchOnReconnect: false, refetchOnWindowFocus: false, } diff --git a/x-pack/plugins/osquery/server/routes/privileges_check/privileges_check_route.ts b/x-pack/plugins/osquery/server/routes/privileges_check/privileges_check_route.ts index 80c335c1c46d3..d9683d23deb13 100644 --- a/x-pack/plugins/osquery/server/routes/privileges_check/privileges_check_route.ts +++ b/x-pack/plugins/osquery/server/routes/privileges_check/privileges_check_route.ts @@ -9,7 +9,6 @@ import { OSQUERY_INTEGRATION_NAME, PLUGIN_ID } from '../../../common'; import { IRouter } from '../../../../../../src/core/server'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars export const privilegesCheckRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.get( { @@ -20,23 +19,26 @@ export const privilegesCheckRoute = (router: IRouter, osqueryContext: OsqueryApp }, }, async (context, request, response) => { - const esClient = context.core.elasticsearch.client.asCurrentUser; - - const privileges = ( - await esClient.security.hasPrivileges({ - body: { - index: [ - { - names: [`logs-${OSQUERY_INTEGRATION_NAME}.result*`], - privileges: ['read'], - }, - ], + if (osqueryContext.security.authz.mode.useRbacForRequest(request)) { + const checkPrivileges = osqueryContext.security.authz.checkPrivilegesDynamicallyWithRequest( + request + ); + const { hasAllRequested } = await checkPrivileges({ + elasticsearch: { + cluster: [], + index: { + [`logs-${OSQUERY_INTEGRATION_NAME}.result*`]: ['read'], + }, }, - }) - ).body; + }); + + return response.ok({ + body: `${hasAllRequested}`, + }); + } return response.ok({ - body: privileges, + body: 'true', }); } ); diff --git a/x-pack/plugins/reporting/common/build_kibana_path.ts b/x-pack/plugins/reporting/common/build_kibana_path.ts new file mode 100644 index 0000000000000..2cb37013300ca --- /dev/null +++ b/x-pack/plugins/reporting/common/build_kibana_path.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +interface Args { + basePath: string; + appPath: string; + spaceId?: string; +} + +export const buildKibanaPath = ({ basePath, appPath, spaceId }: Args) => { + return spaceId === undefined || spaceId.toLowerCase() === 'default' + ? `${basePath}${appPath}` + : `${basePath}/s/${spaceId}${appPath}`; +}; diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index 0e7d8f1ffe9ca..9224a23fcb33f 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -110,12 +110,10 @@ export const REPORTING_REDIRECT_LOCATOR_STORE_KEY = '__REPORTING_REDIRECT_LOCATO /** * A way to get the client side route for the reporting redirect app. * - * This route currently expects a job ID and a locator that to use from that job so that it can redirect to the - * correct page. - * - * TODO: Accommodate 'forceNow' value that some visualizations may rely on + * TODO: Add a job ID and a locator to use so that we can redirect without expecting state to + * be injected to the page */ -export const getRedirectAppPathHome = () => { +export const getRedirectAppPath = () => { return '/app/management/insightsAndAlerting/reporting/r'; }; diff --git a/x-pack/plugins/reporting/public/redirect/redirect_app.tsx b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx index 60b51c0f07895..3024404dc07bf 100644 --- a/x-pack/plugins/reporting/public/redirect/redirect_app.tsx +++ b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx @@ -49,7 +49,7 @@ export const RedirectApp: FunctionComponent = ({ share }) => { ]; if (!locatorParams) { - throw new Error('Could not find locator for report'); + throw new Error('Could not find locator params for report'); } share.navigate(locatorParams); diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index f14bb249524e2..df91b6fe0ba47 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -121,7 +121,7 @@ export class HeadlessChromiumDriver { (key: string, value: unknown) => { Object.defineProperty(window, key, { configurable: false, - writable: false, + writable: true, enumerable: true, value, }); diff --git a/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.ts b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.ts new file mode 100644 index 0000000000000..bb640eff667e9 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.ts @@ -0,0 +1,33 @@ +/* + * 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 { format } from 'url'; +import { ReportingConfig } from '../../..'; +import { getRedirectAppPath } from '../../../../common/constants'; +import { buildKibanaPath } from '../../../../common/build_kibana_path'; + +export function getFullRedirectAppUrl(config: ReportingConfig, spaceId?: string) { + const [basePath, protocol, hostname, port] = [ + config.kbnConfig.get('server', 'basePath'), + config.get('kibanaServer', 'protocol'), + config.get('kibanaServer', 'hostname'), + config.get('kibanaServer', 'port'), + ] as string[]; + + const path = buildKibanaPath({ + basePath, + spaceId, + appPath: getRedirectAppPath(), + }); + + return format({ + protocol, + hostname, + port, + pathname: path, + }); +} diff --git a/x-pack/plugins/reporting/server/export_types/common/v2/get_full_urls.ts b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_urls.ts deleted file mode 100644 index bcfb06784a6dc..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/common/v2/get_full_urls.ts +++ /dev/null @@ -1,34 +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 { parse as urlParse, UrlWithStringQuery } from 'url'; -import { ReportingConfig } from '../../../'; -import { getAbsoluteUrlFactory } from '../get_absolute_url'; -import { validateUrls } from '../validate_urls'; - -export function getFullUrls(config: ReportingConfig, relativeUrls: string[]) { - const [basePath, protocol, hostname, port] = [ - config.kbnConfig.get('server', 'basePath'), - config.get('kibanaServer', 'protocol'), - config.get('kibanaServer', 'hostname'), - config.get('kibanaServer', 'port'), - ] as string[]; - const getAbsoluteUrl = getAbsoluteUrlFactory({ basePath, protocol, hostname, port }); - - validateUrls(relativeUrls); - - const urls = relativeUrls.map((relativeUrl) => { - const parsedRelative: UrlWithStringQuery = urlParse(relativeUrl); - return getAbsoluteUrl({ - path: parsedRelative.pathname === null ? undefined : parsedRelative.pathname, - hash: parsedRelative.hash === null ? undefined : parsedRelative.hash, - search: parsedRelative.search === null ? undefined : parsedRelative.search, - }); - }); - - return urls; -} diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts index 06fdcd93e497c..5e3b3117f4bb5 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts @@ -8,7 +8,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, finalize, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; -import { PNG_JOB_TYPE_V2, getRedirectAppPathHome } from '../../../common/constants'; +import { PNG_JOB_TYPE_V2 } from '../../../common/constants'; import { TaskRunResult } from '../../lib/tasks'; import { RunTaskFn, RunTaskFnFactory } from '../../types'; import { @@ -18,7 +18,7 @@ import { generatePngObservableFactory, setForceNow, } from '../common'; -import { getFullUrls } from '../common/v2/get_full_urls'; +import { getFullRedirectAppUrl } from '../common/v2/get_full_redirect_app_url'; import { TaskPayloadPNGV2 } from './types'; export const runTaskFnFactory: RunTaskFnFactory< @@ -39,8 +39,7 @@ export const runTaskFnFactory: RunTaskFnFactory< map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)), mergeMap((conditionalHeaders) => { - const relativeUrl = getRedirectAppPathHome(); - const [url] = getFullUrls(config, [relativeUrl]); + const url = getFullRedirectAppUrl(config, job.spaceId); const [locatorParams] = job.locatorParams.map(setForceNow(job.forceNow)); apmGetAssets?.end(); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts index 2211e7df08770..f2cf8026c901e 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts @@ -49,7 +49,7 @@ export const runTaskFnFactory: RunTaskFnFactory< apmGeneratePdf = apmTrans?.startSpan('generate_pdf_pipeline', 'execute'); return generatePdfObservable( jobLogger, - jobId, + job, title, locatorParams.map(setForceNow(job.forceNow)), browserTimezone, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts index 9be95223a8864..424a347876a1d 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts @@ -9,14 +9,14 @@ import { groupBy, zip } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { ReportingCore } from '../../../'; -import { getRedirectAppPathHome } from '../../../../common/constants'; import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../../common/types'; import { LevelLogger } from '../../../lib'; import { createLayout, LayoutParams } from '../../../lib/layouts'; import { getScreenshots$, ScreenshotResults } from '../../../lib/screenshots'; import { ConditionalHeaders } from '../../common'; import { PdfMaker } from '../../common/pdf'; -import { getFullUrls } from '../../common/v2/get_full_urls'; +import { getFullRedirectAppUrl } from '../../common/v2/get_full_redirect_app_url'; +import type { TaskPayloadPDFV2 } from '../types'; import { getTracker } from './tracker'; const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { @@ -36,7 +36,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { return function generatePdfObservable( logger: LevelLogger, - jobId: string, + job: TaskPayloadPDFV2, title: string, locatorParams: LocatorParams[], browserTimezone: string | undefined, @@ -56,9 +56,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { /** * For each locator we get the relative URL to the redirect app */ - const relativeUrls = locatorParams.map(() => getRedirectAppPathHome()); - const urls = getFullUrls(reporting.getConfig(), relativeUrls); - + const urls = locatorParams.map(() => getFullRedirectAppUrl(reporting.getConfig(), job.spaceId)); const screenshots$ = getScreenshots$(captureConfig, browserDriverFactory, { logger, urlsOrUrlLocatorTuples: zip(urls, locatorParams) as UrlOrUrlLocatorTuple[], diff --git a/x-pack/plugins/security_solution/cypress/objects/connector.ts b/x-pack/plugins/security_solution/cypress/objects/connector.ts index a5244583bf494..5c2abeab06026 100644 --- a/x-pack/plugins/security_solution/cypress/objects/connector.ts +++ b/x-pack/plugins/security_solution/cypress/objects/connector.ts @@ -12,6 +12,7 @@ export interface EmailConnector { port: string; user: string; password: string; + service: string; } export const getEmailConnector = (): EmailConnector => ({ @@ -21,4 +22,5 @@ export const getEmailConnector = (): EmailConnector => ({ port: '80', user: 'username', password: 'password', + service: 'Other', }); diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index 551857ca3bfca..4748a48dbeb11 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -40,6 +40,8 @@ export const EMAIL_CONNECTOR_USER_INPUT = '[data-test-subj="emailUserInput"]'; export const EMAIL_CONNECTOR_PASSWORD_INPUT = '[data-test-subj="emailPasswordInput"]'; +export const EMAIL_CONNECTOR_SERVICE_SELECTOR = '[data-test-subj="emailServiceSelectInput"]'; + export const ADD_FALSE_POSITIVE_BTN = '[data-test-subj="detectionEngineStepAboutRuleFalsePositives"] .euiButtonEmpty__text'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index e2d27a11ed717..c1210bf457b69 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -92,6 +92,7 @@ import { EMAIL_CONNECTOR_PORT_INPUT, EMAIL_CONNECTOR_USER_INPUT, EMAIL_CONNECTOR_PASSWORD_INPUT, + EMAIL_CONNECTOR_SERVICE_SELECTOR, } from '../screens/create_new_rule'; import { LOADING_INDICATOR } from '../screens/security_header'; import { TOAST_ERROR } from '../screens/shared'; @@ -402,6 +403,7 @@ export const fillIndexAndIndicatorIndexPattern = ( export const fillEmailConnectorForm = (connector: EmailConnector = getEmailConnector()) => { cy.get(CONNECTOR_NAME_INPUT).type(connector.name); + cy.get(EMAIL_CONNECTOR_SERVICE_SELECTOR).select(connector.service); cy.get(EMAIL_CONNECTOR_FROM_INPUT).type(connector.from); cy.get(EMAIL_CONNECTOR_HOST_INPUT).type(connector.host); cy.get(EMAIL_CONNECTOR_PORT_INPUT).type(connector.port); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx index 4fe70039d1251..b15c6b9ba0020 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx @@ -45,7 +45,7 @@ const useLogEntryUIProps = ( if (logEntry.type === 'action') { avatarSize = 'm'; commentType = 'regular'; - commentText = logEntry.item.data.data.comment ?? ''; + commentText = logEntry.item.data.data.comment?.trim() ?? ''; displayResponseEvent = false; iconType = 'lockOpen'; username = logEntry.item.data.user_id; @@ -55,7 +55,7 @@ const useLogEntryUIProps = ( iconType = 'lock'; isIsolateAction = true; } - if (data.comment) { + if (commentText) { displayComment = true; } } @@ -154,7 +154,7 @@ export const LogEntry = memo(({ logEntry }: { logEntry: Immutable {displayComment ? ( - +

{commentText}

) : undefined} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index ea999334ee771..d053da18ce502 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -832,6 +832,15 @@ describe('when on the endpoint list page', () => { }); const actionData = fleetActionGenerator.generate({ agents: [agentId], + data: { + comment: 'some comment', + }, + }); + const isolatedActionData = fleetActionGenerator.generateIsolateAction({ + agents: [agentId], + data: { + comment: ' ', // has space for comment, + }, }); getMockData = () => ({ @@ -854,6 +863,13 @@ describe('when on the endpoint list page', () => { data: actionData, }, }, + { + type: 'action', + item: { + id: 'some_id_3', + data: isolatedActionData, + }, + }, ], }); @@ -890,7 +906,7 @@ describe('when on the endpoint list page', () => { dispatchEndpointDetailsActivityLogChanged('success', getMockData()); }); const logEntries = await renderResult.queryAllByTestId('timelineEntry'); - expect(logEntries.length).toEqual(2); + expect(logEntries.length).toEqual(3); expect(`${logEntries[0]} .euiCommentTimeline__icon--update`).not.toBe(null); expect(`${logEntries[1]} .euiCommentTimeline__icon--regular`).not.toBe(null); }); @@ -947,7 +963,7 @@ describe('when on the endpoint list page', () => { dispatchEndpointDetailsActivityLogChanged('success', getMockData()); }); const logEntries = await renderResult.queryAllByTestId('timelineEntry'); - expect(logEntries.length).toEqual(2); + expect(logEntries.length).toEqual(3); }); it('should display a callout message if no log data', async () => { @@ -975,6 +991,29 @@ describe('when on the endpoint list page', () => { const activityLogCallout = await renderResult.findByTestId('activityLogNoDataCallout'); expect(activityLogCallout).not.toBeNull(); }); + + it('should correctly display non-empty comments only for actions', async () => { + const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); + reactTestingLibrary.act(() => { + history.push( + `${MANAGEMENT_PATH}/endpoints?page_index=0&page_size=10&selected_endpoint=1&show=activity_log` + ); + }); + const changedUrlAction = await userChangedUrlChecker; + expect(changedUrlAction.payload.search).toEqual( + '?page_index=0&page_size=10&selected_endpoint=1&show=activity_log' + ); + await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged'); + reactTestingLibrary.act(() => { + dispatchEndpointDetailsActivityLogChanged('success', getMockData()); + }); + const commentTexts = await renderResult.queryAllByTestId('activityLogCommentText'); + expect(commentTexts.length).toEqual(1); + expect(commentTexts[0].textContent).toEqual('some comment'); + expect(commentTexts[0].parentElement?.parentElement?.className).toContain( + 'euiCommentEvent--regular' + ); + }); }); describe('when showing host Policy Response panel', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts index 1add8bb9d6f76..4d7ca74ca19f8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts @@ -584,6 +584,28 @@ export const AdvancedPolicySchema: AdvancedPolicySchemaType[] = [ } ), }, + { + key: 'windows.advanced.kernel.fileaccess', + first_supported_version: '7.15', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.fileaccess', + { + defaultMessage: + 'Report limited file access (read) events. Paths are not user-configurable. Default value is true.', + } + ), + }, + { + key: 'windows.advanced.kernel.registryaccess', + first_supported_version: '7.15', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.registryaccess', + { + defaultMessage: + 'Report limited registry access (queryvalue, savekey) events. Paths are not user-configurable. Default value is true.', + } + ), + }, { key: 'windows.advanced.diagnostic.enabled', first_supported_version: '7.11', diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx index f52d8a4c70706..06cf666f2950e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx @@ -28,13 +28,13 @@ export const BehaviorProtection = React.memo(() => { const protectionLabel = i18n.translate( 'xpack.securitySolution.endpoint.policy.protections.behavior', { - defaultMessage: 'Behavior', + defaultMessage: 'Malicious behavior protections', } ); return ( { const protectionLabel = i18n.translate( 'xpack.securitySolution.endpoint.policy.protections.memory', { - defaultMessage: 'Memory manipulation', + defaultMessage: 'Memory threat protections', } ); return ( { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/111240 + describe.skip('telemetry collectors fleet', () => { before('generating data', async () => { await getService('esArchiver').load( 'x-pack/test/functional/es_archives/uptime/blank_data_stream' diff --git a/x-pack/test/functional/apps/observability/feature_controls/observability_security.ts b/x-pack/test/functional/apps/observability/feature_controls/observability_security.ts index 1d52088ede3da..69bf995c49ae4 100644 --- a/x-pack/test/functional/apps/observability/feature_controls/observability_security.ts +++ b/x-pack/test/functional/apps/observability/feature_controls/observability_security.ts @@ -10,7 +10,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const security = getService('security'); + const observability = getService('observability'); const PageObjects = getPageObjects([ 'common', 'observability', @@ -20,6 +20,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ]); const appsMenu = getService('appsMenu'); const testSubjects = getService('testSubjects'); + describe('observability security feature controls', function () { this.tags(['skipFirefox']); before(async () => { @@ -32,39 +33,20 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('observability cases all privileges', () => { before(async () => { - await security.role.create('cases_observability_all_role', { - elasticsearch: { cluster: [], indices: [], run_as: [] }, - kibana: [ - { spaces: ['*'], base: [], feature: { observabilityCases: ['all'], logs: ['all'] } }, - ], - }); - - await security.user.create('cases_observability_all_user', { - password: 'cases_observability_all_user-password', - roles: ['cases_observability_all_role'], - full_name: 'test user', - }); - - await PageObjects.security.forceLogout(); - - await PageObjects.security.login( - 'cases_observability_all_user', - 'cases_observability_all_user-password', - { - expectSpaceSelector: false, - } + await observability.users.setTestUserRole( + observability.users.defineBasicObservabilityRole({ + observabilityCases: ['all'], + logs: ['all'], + }) ); }); after(async () => { - await PageObjects.security.forceLogout(); - await Promise.all([ - security.role.delete('cases_observability_all_role'), - security.user.delete('cases_observability_all_user'), - ]); + await observability.users.restoreDefaultTestUserRole(); }); it('shows observability/cases navlink', async () => { + await PageObjects.common.navigateToActualUrl('observability'); const navLinks = (await appsMenu.readLinks()).map((link) => link.text); expect(navLinks).to.contain('Cases'); }); @@ -101,38 +83,20 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('observability cases read-only privileges', () => { before(async () => { - await security.role.create('cases_observability_read_role', { - elasticsearch: { cluster: [], indices: [], run_as: [] }, - kibana: [ - { - spaces: ['*'], - base: [], - feature: { observabilityCases: ['read'], logs: ['all'] }, - }, - ], - }); - - await security.user.create('cases_observability_read_user', { - password: 'cases_observability_read_user-password', - roles: ['cases_observability_read_role'], - full_name: 'test user', - }); - - await PageObjects.security.login( - 'cases_observability_read_user', - 'cases_observability_read_user-password', - { - expectSpaceSelector: false, - } + await observability.users.setTestUserRole( + observability.users.defineBasicObservabilityRole({ + observabilityCases: ['read'], + logs: ['all'], + }) ); }); after(async () => { - await security.role.delete('cases_observability_read_role'); - await security.user.delete('cases_observability_read_user'); + await observability.users.restoreDefaultTestUserRole(); }); it('shows observability/cases navlink', async () => { + await PageObjects.common.navigateToActualUrl('observability'); const navLinks = (await appsMenu.readLinks()).map((link) => link.text); expect(navLinks).to.contain('Cases'); }); @@ -170,36 +134,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('no observability privileges', () => { before(async () => { - await security.role.create('no_observability_privileges_role', { + await observability.users.setTestUserRole({ elasticsearch: { cluster: [], indices: [], run_as: [] }, - kibana: [ - { - feature: { - discover: ['all'], - }, - spaces: ['*'], - }, - ], + kibana: [{ spaces: ['*'], base: [], feature: { discover: ['all'] } }], }); - - await security.user.create('no_observability_privileges_user', { - password: 'no_observability_privileges_user-password', - roles: ['no_observability_privileges_role'], - full_name: 'test user', - }); - - await PageObjects.security.login( - 'no_observability_privileges_user', - 'no_observability_privileges_user-password', - { - expectSpaceSelector: false, - } - ); }); after(async () => { - await security.role.delete('no_observability_privileges_role'); - await security.user.delete('no_observability_privileges_user'); + await observability.users.restoreDefaultTestUserRole(); }); it(`returns a 403`, async () => { diff --git a/x-pack/test/functional/services/index.ts b/x-pack/test/functional/services/index.ts index 273db212400ab..5e40eb040178b 100644 --- a/x-pack/test/functional/services/index.ts +++ b/x-pack/test/functional/services/index.ts @@ -60,6 +60,7 @@ import { DashboardPanelTimeRangeProvider, } from './dashboard'; import { SearchSessionsService } from './search_sessions'; +import { ObservabilityProvider } from './observability'; // define the name and providers for services that should be // available to your tests. If you don't specify anything here @@ -110,4 +111,5 @@ export const services = { dashboardPanelTimeRange: DashboardPanelTimeRangeProvider, reporting: ReportingFunctionalProvider, searchSessions: SearchSessionsService, + observability: ObservabilityProvider, }; diff --git a/x-pack/test/functional/services/observability/index.ts b/x-pack/test/functional/services/observability/index.ts new file mode 100644 index 0000000000000..14f931d93b56f --- /dev/null +++ b/x-pack/test/functional/services/observability/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; +import { ObservabilityUsersProvider } from './users'; + +export function ObservabilityProvider(context: FtrProviderContext) { + const users = ObservabilityUsersProvider(context); + + return { + users, + }; +} diff --git a/x-pack/test/functional/services/observability/users.ts b/x-pack/test/functional/services/observability/users.ts new file mode 100644 index 0000000000000..78e8b3346cc67 --- /dev/null +++ b/x-pack/test/functional/services/observability/users.ts @@ -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 { Role } from '../../../../plugins/security/common/model'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +type CreateRolePayload = Pick; + +const OBSERVABILITY_TEST_ROLE_NAME = 'observability-functional-test-role'; + +export function ObservabilityUsersProvider({ getPageObject, getService }: FtrProviderContext) { + const security = getService('security'); + const commonPageObject = getPageObject('common'); + + /** + * Creates a test role and set it as the test user's role. Performs a page + * reload to apply the role change, but doesn't require a re-login. + * + * @arg roleDefinition - the privileges of the test role + */ + const setTestUserRole = async (roleDefinition: CreateRolePayload) => { + // return to neutral grounds to avoid running into permission problems on reload + await commonPageObject.navigateToActualUrl('kibana'); + + await security.role.create(OBSERVABILITY_TEST_ROLE_NAME, roleDefinition); + + await security.testUser.setRoles([OBSERVABILITY_TEST_ROLE_NAME]); // performs a page reload + }; + + /** + * Deletes the test role and restores thedefault test user role. Performs a + * page reload to apply the role change, but doesn't require a re-login. + */ + const restoreDefaultTestUserRole = async () => { + await Promise.all([ + security.role.delete(OBSERVABILITY_TEST_ROLE_NAME), + security.testUser.restoreDefaults(), + ]); + }; + + return { + defineBasicObservabilityRole, + restoreDefaultTestUserRole, + setTestUserRole, + }; +} + +/** + * Generates a combination of Elasticsearch and Kibana privileges for given + * observability features. + */ +const defineBasicObservabilityRole = ( + features: Partial<{ + observabilityCases: string[]; + apm: string[]; + logs: string[]; + infrastructure: string[]; + uptime: string[]; + }> +): CreateRolePayload => { + return { + elasticsearch: { + cluster: ['all'], + indices: [ + ...((features.logs?.length ?? 0) > 0 + ? [{ names: ['filebeat-*', 'logs-*'], privileges: ['all'] }] + : []), + ...((features.infrastructure?.length ?? 0) > 0 + ? [{ names: ['metricbeat-*', 'metrics-*'], privileges: ['all'] }] + : []), + ...((features.apm?.length ?? 0) > 0 ? [{ names: ['apm-*'], privileges: ['all'] }] : []), + ...((features.uptime?.length ?? 0) > 0 + ? [{ names: ['heartbeat-*,synthetics-*'], privileges: ['all'] }] + : []), + ], + run_as: [], + }, + kibana: [ + { + spaces: ['*'], + base: [], + // @ts-expect-error TypeScript doesn't distinguish between missing and + // undefined props yet + feature: features, + }, + ], + }; +}; diff --git a/yarn.lock b/yarn.lock index 4d49a2f06e1e9..f0a1ff1278f4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23379,10 +23379,10 @@ react-popper@^2.2.4: react-fast-compare "^3.0.1" warning "^4.0.2" -react-query@^3.21.0: - version "3.21.0" - resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.21.0.tgz#2e099a7906c38eeeb750e8b9b12121a21fa8d9ef" - integrity sha512-5rY5J8OD9f4EdkytjSsdCO+pqbJWKwSIMETfh/UyxqyjLURHE0IhlB+IPNPrzzu/dzK0rRxi5p0IkcCdSfizDQ== +react-query@^3.21.1: + version "3.21.1" + resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.21.1.tgz#8fe4df90bf6c6a93e0552ea9baff211d1b28f6e0" + integrity sha512-aKFLfNJc/m21JBXJk7sR9tDUYPjotWA4EHAKvbZ++GgxaY+eI0tqBxXmGBuJo0Pisis1W4pZWlZgoRv9yE8yjA== dependencies: "@babel/runtime" "^7.5.5" broadcast-channel "^3.4.1"