diff --git a/.buildkite/scripts/bootstrap.sh b/.buildkite/scripts/bootstrap.sh index 7ae925b262b9f..05a250f8e9e8c 100755 --- a/.buildkite/scripts/bootstrap.sh +++ b/.buildkite/scripts/bootstrap.sh @@ -5,7 +5,7 @@ set -euo pipefail source .buildkite/scripts/common/util.sh echo "--- yarn install and bootstrap" -yarn kbn bootstrap --verbose +yarn kbn bootstrap ### ### upload ts-refs-cache artifacts as quickly as possible so they are available for download diff --git a/.buildkite/scripts/build_kibana_plugins.sh b/.buildkite/scripts/build_kibana_plugins.sh index f4d82699ef92d..14ea71a75bae6 100644 --- a/.buildkite/scripts/build_kibana_plugins.sh +++ b/.buildkite/scripts/build_kibana_plugins.sh @@ -18,8 +18,7 @@ node scripts/build_kibana_platform_plugins \ --scan-dir "$XPACK_DIR/test/licensing_plugin/plugins" \ --scan-dir "$XPACK_DIR/test/usage_collection/plugins" \ --scan-dir "$XPACK_DIR/test/security_functional/fixtures/common" \ - --scan-dir "$XPACK_DIR/examples" \ - --verbose + --scan-dir "$XPACK_DIR/examples" echo "--- Archive built plugins" shopt -s globstar diff --git a/.buildkite/scripts/lifecycle/build_status.js b/.buildkite/scripts/lifecycle/build_status.js index 2c1d51ecac0a7..f2a5024c96013 100644 --- a/.buildkite/scripts/lifecycle/build_status.js +++ b/.buildkite/scripts/lifecycle/build_status.js @@ -7,11 +7,11 @@ const { BuildkiteClient } = require('kibana-buildkite-library'); console.log(status.success ? 'true' : 'false'); process.exit(0); } catch (ex) { + console.error('Buildkite API Error', ex.message); if (ex.response) { - console.error('HTTP Error Response Body', ex.response.data); console.error('HTTP Error Response Status', ex.response.status); + console.error('HTTP Error Response Body', ex.response.data); } - console.error(ex); process.exit(1); } })(); diff --git a/.buildkite/scripts/lifecycle/ci_stats_complete.js b/.buildkite/scripts/lifecycle/ci_stats_complete.js index d86e2ec7efcae..d9411178799ab 100644 --- a/.buildkite/scripts/lifecycle/ci_stats_complete.js +++ b/.buildkite/scripts/lifecycle/ci_stats_complete.js @@ -4,7 +4,11 @@ const { CiStats } = require('kibana-buildkite-library'); try { await CiStats.onComplete(); } catch (ex) { - console.error(ex); + console.error('CI Stats Error', ex.message); + if (ex.response) { + console.error('HTTP Error Response Status', ex.response.status); + console.error('HTTP Error Response Body', ex.response.data); + } process.exit(1); } })(); diff --git a/.buildkite/scripts/lifecycle/ci_stats_start.js b/.buildkite/scripts/lifecycle/ci_stats_start.js index 115aa9bd23954..ec0e4c713499e 100644 --- a/.buildkite/scripts/lifecycle/ci_stats_start.js +++ b/.buildkite/scripts/lifecycle/ci_stats_start.js @@ -4,7 +4,11 @@ const { CiStats } = require('kibana-buildkite-library'); try { await CiStats.onStart(); } catch (ex) { - console.error(ex); + console.error('CI Stats Error', ex.message); + if (ex.response) { + console.error('HTTP Error Response Status', ex.response.status); + console.error('HTTP Error Response Body', ex.response.data); + } process.exit(1); } })(); diff --git a/.ci/Dockerfile b/.ci/Dockerfile index 947242ecc0ece..d3ea74ca38969 100644 --- a/.ci/Dockerfile +++ b/.ci/Dockerfile @@ -1,7 +1,7 @@ # NOTE: This Dockerfile is ONLY used to run certain tasks in CI. It is not used to run Kibana or as a distributable. # If you're looking for the Kibana Docker image distributable, please see: src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts -ARG NODE_VERSION=14.17.5 +ARG NODE_VERSION=14.17.6 FROM node:${NODE_VERSION} AS base diff --git a/.ci/Jenkinsfile_baseline_trigger b/.ci/Jenkinsfile_baseline_trigger index 221b7a44e30df..fd1c267fb3301 100644 --- a/.ci/Jenkinsfile_baseline_trigger +++ b/.ci/Jenkinsfile_baseline_trigger @@ -1,5 +1,7 @@ #!/bin/groovy +return + def MAXIMUM_COMMITS_TO_CHECK = 10 def MAXIMUM_COMMITS_TO_BUILD = 5 diff --git a/.eslintrc.js b/.eslintrc.js index 06fd805a02aa7..7abc354ed8957 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1656,5 +1656,24 @@ module.exports = { '@typescript-eslint/prefer-ts-expect-error': 'error', }, }, + + /** + * Disallow `export *` syntax in plugin/core public/server/common index files and instead + * require that plugins/core explicitly export the APIs that should be accessible outside the plugin. + * + * To add your plugin to this list just update the relevant glob with the name of your plugin + */ + { + files: [ + 'src/core/{server,public,common}/index.ts', + 'src/plugins/*/{server,public,common}/index.ts', + 'src/plugins/*/*/{server,public,common}/index.ts', + 'x-pack/plugins/*/{server,public,common}/index.ts', + 'x-pack/plugins/*/*/{server,public,common}/index.ts', + ], + rules: { + '@kbn/eslint/no_export_all': 'error', + }, + }, ], }; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 74a206ea98e05..381fad404ca73 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -12,31 +12,34 @@ # Virtual teams /x-pack/plugins/rule_registry/ @elastic/rac -# App -/x-pack/plugins/discover_enhanced/ @elastic/kibana-app -/x-pack/plugins/lens/ @elastic/kibana-app -/x-pack/plugins/graph/ @elastic/kibana-app -/src/plugins/advanced_settings/ @elastic/kibana-app -/src/plugins/charts/ @elastic/kibana-app -/src/plugins/discover/ @elastic/kibana-app -/src/plugins/management/ @elastic/kibana-app -/src/plugins/kibana_legacy/ @elastic/kibana-app -/src/plugins/timelion/ @elastic/kibana-app -/src/plugins/vis_default_editor/ @elastic/kibana-app -/src/plugins/vis_type_metric/ @elastic/kibana-app -/src/plugins/vis_type_table/ @elastic/kibana-app -/src/plugins/vis_type_tagcloud/ @elastic/kibana-app -/src/plugins/vis_type_timelion/ @elastic/kibana-app -/src/plugins/vis_type_timeseries/ @elastic/kibana-app -/src/plugins/vis_type_vega/ @elastic/kibana-app -/src/plugins/vis_types/vislib/ @elastic/kibana-app -/src/plugins/vis_types/xy/ @elastic/kibana-app -/src/plugins/vis_types/pie/ @elastic/kibana-app -/src/plugins/visualize/ @elastic/kibana-app -/src/plugins/visualizations/ @elastic/kibana-app -/src/plugins/chart_expressions/expression_tagcloud/ @elastic/kibana-app -/src/plugins/url_forwarding/ @elastic/kibana-app -/packages/kbn-tinymath/ @elastic/kibana-app +# Data Discovery +/src/plugins/discover/ @elastic/kibana-data-discovery +/x-pack/plugins/discover_enhanced/ @elastic/kibana-data-discovery +/test/functional/apps/discover/ @elastic/kibana-data-discovery + +# Vis Editors +/x-pack/plugins/lens/ @elastic/kibana-vis-editors +/x-pack/plugins/graph/ @elastic/kibana-vis-editors +/src/plugins/advanced_settings/ @elastic/kibana-vis-editors +/src/plugins/charts/ @elastic/kibana-vis-editors +/src/plugins/management/ @elastic/kibana-vis-editors +/src/plugins/kibana_legacy/ @elastic/kibana-vis-editors +/src/plugins/timelion/ @elastic/kibana-vis-editors +/src/plugins/vis_default_editor/ @elastic/kibana-vis-editors +/src/plugins/vis_type_metric/ @elastic/kibana-vis-editors +/src/plugins/vis_type_table/ @elastic/kibana-vis-editors +/src/plugins/vis_type_tagcloud/ @elastic/kibana-vis-editors +/src/plugins/vis_type_timelion/ @elastic/kibana-vis-editors +/src/plugins/vis_type_timeseries/ @elastic/kibana-vis-editors +/src/plugins/vis_type_vega/ @elastic/kibana-vis-editors +/src/plugins/vis_types/vislib/ @elastic/kibana-vis-editors +/src/plugins/vis_types/xy/ @elastic/kibana-vis-editors +/src/plugins/vis_types/pie/ @elastic/kibana-vis-editors +/src/plugins/visualize/ @elastic/kibana-vis-editors +/src/plugins/visualizations/ @elastic/kibana-vis-editors +/src/plugins/chart_expressions/expression_tagcloud/ @elastic/kibana-vis-editors +/src/plugins/url_forwarding/ @elastic/kibana-vis-editors +/packages/kbn-tinymath/ @elastic/kibana-vis-editors # Application Services /examples/bfetch_explorer/ @elastic/kibana-app-services @@ -436,6 +439,9 @@ /x-pack/test/reporting_api_integration/ @elastic/kibana-reporting-services @elastic/kibana-app-services /x-pack/test/reporting_functional/ @elastic/kibana-reporting-services @elastic/kibana-app-services /x-pack/test/stack_functional_integration/apps/reporting/ @elastic/kibana-reporting-services @elastic/kibana-app-services +/docs/user/reporting @elastic/kibana-reporting-services @elastic/kibana-app-services +/docs/settings/reporting-settings.asciidoc @elastic/kibana-reporting-services @elastic/kibana-app-services +/docs/setup/configuring-reporting.asciidoc @elastic/kibana-reporting-services @elastic/kibana-app-services #CC# /x-pack/plugins/reporting/ @elastic/kibana-reporting-services diff --git a/.github/workflows/project-assigner.yml b/.github/workflows/project-assigner.yml index 7e658197c7a84..8b32b7d699c7a 100644 --- a/.github/workflows/project-assigner.yml +++ b/.github/workflows/project-assigner.yml @@ -14,7 +14,7 @@ jobs: issue-mappings: | [ {"label": "Feature:Lens", "projectNumber": 32, "columnName": "Long-term goals"}, - {"label": "Feature:Discover", "projectNumber": 44, "columnName": "Inbox"}, + {"label": "Team:DataDiscovery", "projectNumber": 44, "columnName": "Inbox"}, {"label": "Feature:Canvas", "projectNumber": 38, "columnName": "Inbox"}, {"label": "Feature:Dashboard", "projectNumber": 68, "columnName": "Inbox"}, {"label": "Feature:Drilldowns", "projectNumber": 68, "columnName": "Inbox"}, diff --git a/.github/workflows/sync-main-branch.yml b/.github/workflows/sync-main-branch.yml new file mode 100644 index 0000000000000..63465602e8436 --- /dev/null +++ b/.github/workflows/sync-main-branch.yml @@ -0,0 +1,26 @@ +# Synchronize all pushes to 'master' branch with 'main' branch to facilitate migration +name: "Sync main branch" +on: + push: + branches: + - master + +jobs: + sync_latest_from_upstream: + runs-on: ubuntu-latest + name: Sync latest commits from master branch + + steps: + - name: Checkout target repo + uses: actions/checkout@v2 + with: + ref: main + + - name: Sync upstream changes + id: sync + uses: aormsby/Fork-Sync-With-Upstream-action@v3.0 + with: + target_sync_branch: main + target_repo_token: ${{ secrets.KIBANAMACHINE_TOKEN }} + upstream_sync_branch: master + upstream_sync_repo: elastic/kibana diff --git a/.i18nrc.json b/.i18nrc.json index 3301cd04ad06c..f38d6b8faae7e 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -2,6 +2,7 @@ "paths": { "alerts": "packages/kbn-alerts/src", "autocomplete": "packages/kbn-securitysolution-autocomplete/src", + "kbnConfig": "packages/kbn-config/src", "console": "src/plugins/console", "core": "src/core", "discover": "src/plugins/discover", diff --git a/.node-version b/.node-version index 18711d290eac4..5595ae1aa9e4c 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -14.17.5 +14.17.6 diff --git a/.nvmrc b/.nvmrc index 18711d290eac4..5595ae1aa9e4c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -14.17.5 +14.17.6 diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index 384277822709c..3ae3f202a3bfd 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -27,13 +27,13 @@ check_rules_nodejs_version(minimum_version_string = "3.8.0") # we can update that rule. node_repositories( node_repositories = { - "14.17.5-darwin_amd64": ("node-v14.17.5-darwin-x64.tar.gz", "node-v14.17.5-darwin-x64", "2e40ab625b45b9bdfcb963ddd4d65d87ddf1dd37a86b6f8b075cf3d77fe9dc09"), - "14.17.5-linux_arm64": ("node-v14.17.5-linux-arm64.tar.xz", "node-v14.17.5-linux-arm64", "3a2e674b6db50dfde767c427e8f077235bbf6f9236e1b12a4cc3496b12f94bae"), - "14.17.5-linux_s390x": ("node-v14.17.5-linux-s390x.tar.xz", "node-v14.17.5-linux-s390x", "7d40eee3d54241403db12fb3bc420cd776e2b02e89100c45cf5e74a73942e7f6"), - "14.17.5-linux_amd64": ("node-v14.17.5-linux-x64.tar.xz", "node-v14.17.5-linux-x64", "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff"), - "14.17.5-windows_amd64": ("node-v14.17.5-win-x64.zip", "node-v14.17.5-win-x64", "a99b7ee08e846e5d1f4e70c4396265542819d79ed9cebcc27760b89571f03cbf"), + "14.17.6-darwin_amd64": ("node-v14.17.6-darwin-x64.tar.gz", "node-v14.17.6-darwin-x64", "e3e4c02240d74fb1dc8a514daa62e5de04f7eaee0bcbca06a366ece73a52ad88"), + "14.17.6-linux_arm64": ("node-v14.17.6-linux-arm64.tar.xz", "node-v14.17.6-linux-arm64", "9c4f3a651e03cd9b5bddd33a80e8be6a6eb15e518513e410bb0852a658699156"), + "14.17.6-linux_s390x": ("node-v14.17.6-linux-s390x.tar.xz", "node-v14.17.6-linux-s390x", "3677f35b97608056013b5368f86eecdb044bdccc1b3976c1d4448736c37b6a0c"), + "14.17.6-linux_amd64": ("node-v14.17.6-linux-x64.tar.xz", "node-v14.17.6-linux-x64", "3bbe4faf356738d88b45be222bf5e858330541ff16bd0d4cfad36540c331461b"), + "14.17.6-windows_amd64": ("node-v14.17.6-win-x64.zip", "node-v14.17.6-win-x64", "b83e9ce542fda7fc519cec6eb24a2575a84862ea4227dedc171a8e0b5b614ac0"), }, - node_version = "14.17.5", + node_version = "14.17.6", node_urls = [ "https://nodejs.org/dist/v{version}/{filename}", ], diff --git a/dev_docs/building_blocks.mdx b/dev_docs/building_blocks.mdx index 327492a20d5b8..6320a7db4558c 100644 --- a/dev_docs/building_blocks.mdx +++ b/dev_docs/building_blocks.mdx @@ -62,7 +62,7 @@ Check out the Lens Embeddable if you wish to show users visualizations based on and . Using the same configuration, it's also possible to link to a prefilled Lens editor, allowing the user to drill deeper and explore their data. -**Github labels**: `Team:KibanaApp`, `Feature:Lens` +**Github labels**: `Team:VisEditors`, `Feature:Lens` ### Map Embeddable diff --git a/docs/apm/correlations.asciidoc b/docs/apm/correlations.asciidoc index 45781228cd200..c0c18433c9021 100644 --- a/docs/apm/correlations.asciidoc +++ b/docs/apm/correlations.asciidoc @@ -12,7 +12,7 @@ piece of hardware, like a host or pod. Or, perhaps a set of users, based on IP address or region, is facing increased latency due to local data center issues. To find correlations, select a service on the *Services* page in the {apm-app} -and click **View correlations**. +then select a transaction group from the *Transactions* tab. NOTE: Queries within the {apm-app} are also applied to the correlations. @@ -20,26 +20,25 @@ NOTE: Queries within the {apm-app} are also applied to the correlations. [[correlations-latency]] ==== Find high transaction latency correlations -The correlations on the *Latency* tab help you discover which attributes are -contributing to increased transaction latency. +The correlations on the *Latency correlations* tab help you discover which +attributes are contributing to increased transaction latency. [role="screenshot"] image::apm/images/correlations-hover.png[Latency correlations] The progress bar indicates the status of the asynchronous analysis, which performs statistical searches across a large number of attributes. For large -time ranges and services with high transaction throughput this might take some -time. To improve performance, reduce the time range on the service overview -page. +time ranges and services with high transaction throughput, this might take some +time. To improve performance, reduce the time range. The latency distribution chart visualizes the overall latency of the -transactions in the service. If there are attributes that have a statistically -significant correlation with slow response times, they are listed in a table -below the chart. The table is sorted by correlation coefficients that range from -0 to 1. Attributes with higher correlation values are more likely to contribute -to high latency transactions. By default, the attribute with the highest -correlation value is added to the chart. To see the latency distribution for -other attributes, hover over their row in the table. +transactions in the transaction group. If there are attributes that have a +statistically significant correlation with slow response times, they are listed +in a table below the chart. The table is sorted by correlation coefficients that +range from 0 to 1. Attributes with higher correlation values are more likely to +contribute to high latency transactions. By default, the attribute with the +highest correlation value is added to the chart. To see the latency distribution +for other attributes, hover over their row in the table. If a correlated attribute seems noteworthy, use the **Filter** quick links: diff --git a/docs/apm/images/correlations-hover.png b/docs/apm/images/correlations-hover.png index c8d5622156b4c..80c1fa41adbdf 100644 Binary files a/docs/apm/images/correlations-hover.png and b/docs/apm/images/correlations-hover.png differ 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 cadb34ae63b86..bc6075176cd22 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 @@ -9,6 +9,10 @@ ```typescript readonly links: { readonly settings: string; + readonly apm: { + readonly kibanaSettings: string; + readonly supportedServiceMaps: string; + }; readonly canvas: { readonly guide: string; }; @@ -128,6 +132,7 @@ readonly links: { readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { + readonly privileges: string; readonly guide: string; readonly gettingStarted: string; readonly ml: 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 aded69733b58b..aa3f958018041 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly 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 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 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/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md index e5f08213da510..bd0fc1e5b3713 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md @@ -18,6 +18,7 @@ export interface DeprecationsDetails | [deprecationType](./kibana-plugin-core-server.deprecationsdetails.deprecationtype.md) | 'config' | 'feature' | (optional) Used to identify between different deprecation types. Example use case: in Upgrade Assistant, we may want to allow the user to sort by deprecation type or show each type in a separate tab.Feel free to add new types if necessary. Predefined types are necessary to reduce having similar definitions with different keywords across kibana deprecations. | | [documentationUrl](./kibana-plugin-core-server.deprecationsdetails.documentationurl.md) | string | | | [level](./kibana-plugin-core-server.deprecationsdetails.level.md) | 'warning' | 'critical' | 'fetch_error' | levels: - warning: will not break deployment upon upgrade - critical: needs to be addressed before upgrade. - fetch\_error: Deprecations service failed to grab the deprecation details for the domain. | -| [message](./kibana-plugin-core-server.deprecationsdetails.message.md) | string | | +| [message](./kibana-plugin-core-server.deprecationsdetails.message.md) | string | The description message to be displayed for the deprecation. Check the README for writing deprecations in src/core/server/deprecations/README.mdx | | [requireRestart](./kibana-plugin-core-server.deprecationsdetails.requirerestart.md) | boolean | | +| [title](./kibana-plugin-core-server.deprecationsdetails.title.md) | string | The title of the deprecation. Check the README for writing deprecations in src/core/server/deprecations/README.mdx | diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.message.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.message.md index d79a4c9bd7995..906ce8118f95b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.message.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.message.md @@ -4,6 +4,8 @@ ## DeprecationsDetails.message property +The description message to be displayed for the deprecation. Check the README for writing deprecations in `src/core/server/deprecations/README.mdx` + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.title.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.title.md new file mode 100644 index 0000000000000..e8907688f6e5e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.title.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [DeprecationsDetails](./kibana-plugin-core-server.deprecationsdetails.md) > [title](./kibana-plugin-core-server.deprecationsdetails.title.md) + +## DeprecationsDetails.title property + +The title of the deprecation. Check the README for writing deprecations in `src/core/server/deprecations/README.mdx` + +Signature: + +```typescript +title: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md index 7d9d3dcdda4da..75732f59f1b3f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md @@ -21,6 +21,7 @@ export interface DeprecationsServiceSetup ```ts import { DeprecationsDetails, GetDeprecationsContext, CoreSetup } from 'src/core/server'; +import { i18n } from '@kbn/i18n'; async function getDeprecations({ esClient, savedObjectsClient }: GetDeprecationsContext): Promise { const deprecations: DeprecationsDetails[] = []; @@ -29,52 +30,44 @@ async function getDeprecations({ esClient, savedObjectsClient }: GetDeprecations if (count > 0) { // Example of a manual correctiveAction deprecations.push({ - message: `You have ${count} Timelion worksheets. The Timelion app will be removed in 8.0. To continue using your Timelion worksheets, migrate them to a dashboard.`, + title: i18n.translate('xpack.timelion.deprecations.worksheetsTitle', { + defaultMessage: 'Found Timelion worksheets.' + }), + message: i18n.translate('xpack.timelion.deprecations.worksheetsMessage', { + defaultMessage: 'You have {count} Timelion worksheets. The Timelion app will be removed in 8.0. To continue using your Timelion worksheets, migrate them to a dashboard.', + values: { count }, + }), documentationUrl: 'https://www.elastic.co/guide/en/kibana/current/create-panels-with-timelion.html', level: 'warning', correctiveActions: { manualSteps: [ - 'Navigate to the Kibana Dashboard and click "Create dashboard".', - 'Select Timelion from the "New Visualization" window.', - 'Open a new tab, open the Timelion app, select the chart you want to copy, then copy the chart expression.', - 'Go to Timelion, paste the chart expression in the Timelion expression field, then click Update.', - 'In the toolbar, click Save.', - 'On the Save visualization window, enter the visualization Title, then click Save and return.', + i18n.translate('xpack.timelion.deprecations.worksheets.manualStepOneMessage', { + defaultMessage: 'Navigate to the Kibana Dashboard and click "Create dashboard".', + }), + i18n.translate('xpack.timelion.deprecations.worksheets.manualStepTwoMessage', { + defaultMessage: 'Select Timelion from the "New Visualization" window.', + }), ], + api: { + path: '/internal/security/users/test_dashboard_user', + method: 'POST', + body: { + username: 'test_dashboard_user', + roles: [ + "machine_learning_user", + "enrich_user", + "kibana_admin" + ], + full_name: "Alison Goryachev", + email: "alisongoryachev@gmail.com", + metadata: {}, + enabled: true + } + }, }, }); } - - // Example of an api correctiveAction - deprecations.push({ - "message": "User 'test_dashboard_user' is using a deprecated role: 'kibana_user'", - "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html", - "level": "critical", - "correctiveActions": { - "api": { - "path": "/internal/security/users/test_dashboard_user", - "method": "POST", - "body": { - "username": "test_dashboard_user", - "roles": [ - "machine_learning_user", - "enrich_user", - "kibana_admin" - ], - "full_name": "Alison Goryachev", - "email": "alisongoryachev@gmail.com", - "metadata": {}, - "enabled": true - } - }, - "manualSteps": [ - "Using Kibana user management, change all users using the kibana_user role to the kibana_admin role.", - "Using Kibana role-mapping management, change all role-mappings which assing the kibana_user role to the kibana_admin role." - ] - }, - }); - return deprecations; } diff --git a/docs/discover/images/add-field-to-pattern.png b/docs/discover/images/add-field-to-pattern.png index 9a206f5f1bd1d..54d6610ca7bb4 100644 Binary files a/docs/discover/images/add-field-to-pattern.png and b/docs/discover/images/add-field-to-pattern.png differ diff --git a/docs/discover/images/customer.png b/docs/discover/images/customer.png index 4c1ff2f2fddbd..904741631eb34 100644 Binary files a/docs/discover/images/customer.png and b/docs/discover/images/customer.png differ diff --git a/docs/discover/images/discover-from-visualize.png b/docs/discover/images/discover-from-visualize.png index 42d46e6cbd5b5..6c976f01bc9f4 100644 Binary files a/docs/discover/images/discover-from-visualize.png and b/docs/discover/images/discover-from-visualize.png differ diff --git a/docs/discover/images/discover-search-for-relevance.png b/docs/discover/images/discover-search-for-relevance.png index 64cfd87b7aac2..15945b3515530 100644 Binary files a/docs/discover/images/discover-search-for-relevance.png and b/docs/discover/images/discover-search-for-relevance.png differ diff --git a/docs/discover/images/document-table-expanded.png b/docs/discover/images/document-table-expanded.png index ebbd2e607eb5a..3abc9ee7c1cbf 100644 Binary files a/docs/discover/images/document-table-expanded.png and b/docs/discover/images/document-table-expanded.png differ diff --git a/docs/discover/images/document-table.png b/docs/discover/images/document-table.png index 5b5dbc08d6e64..98764f34350bf 100644 Binary files a/docs/discover/images/document-table.png and b/docs/discover/images/document-table.png differ diff --git a/docs/discover/images/double-arrow.png b/docs/discover/images/double-arrow.png index ba4ee11ebf738..80b87b4a35326 100644 Binary files a/docs/discover/images/double-arrow.png and b/docs/discover/images/double-arrow.png differ diff --git a/docs/discover/images/downward-arrow.png b/docs/discover/images/downward-arrow.png index 47b03cfe82b34..a0b153bfe3b39 100644 Binary files a/docs/discover/images/downward-arrow.png and b/docs/discover/images/downward-arrow.png differ diff --git a/docs/discover/images/hello-field.png b/docs/discover/images/hello-field.png index 5c6348d4e90fe..fc2c79c13a5d2 100644 Binary files a/docs/discover/images/hello-field.png and b/docs/discover/images/hello-field.png differ diff --git a/docs/discover/search-for-relevance.asciidoc b/docs/discover/search-for-relevance.asciidoc index f3cf1c3a7f52c..eab310c1b5b01 100644 --- a/docs/discover/search-for-relevance.asciidoc +++ b/docs/discover/search-for-relevance.asciidoc @@ -1,6 +1,5 @@ [[discover-search-for-relevance]] == Search for relevance -Sometimes you might be unsure which documents best match your search. {es} assigns a relevancy, or score to each document, so you can can narrow your search to the documents with the most relevant results. The higher the score, the better it matches your query. @@ -12,9 +11,7 @@ the <>, or you can use your ow . In *Discover*, open the index pattern dropdown, and select that data you want to work with. + For the sample flights data, set the index pattern to *kibana_sample_data_flights*. -. In the query bar, click *KQL*, and then turn it off. -+ -You're now using the <>. + . Run your search. For the sample data, try: + ```ts @@ -22,15 +19,15 @@ Warsaw OR Venice OR Clear ``` . If you don't see any results, expand the <>, for example to *Last 7 days*. . From the list of *Available fields*, add `_score` and any other fields you want to the document table. -. To sort the `_score` column in descending order, hover over its header, and then click twice on -the arrow icon -image:images/double-arrow.png[Double arrow icon to indicate sorting] so it changes to +. To sort the `_score` column in descending order, hover over its header, and set +the sort icon to image:images/downward-arrow.png[Downward pointing arrow to indicate descending sorting]. + At this point, you're doing a multi-column sort: first by `Time`, and then by `_score`. -. To turn off sorting for the `Time` field, hover over its header, and then click the down arrow. +. To turn off sorting for the `Time` field, hover over its header, and set the sort icon to +image:images/double-arrow.png[Arrow on both ends of the icon indicates sorting is off]. + Your table now sorts documents from most to least relevant. + [role="screenshot"] -image::images/discover-search-for-relevance.png["Example of a search for relevance"] +image::images/discover-search-for-relevance.png["Documents are sorted from most relevant to least relevant."] diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 49adc72bbe346..a4863bd60089b 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -186,7 +186,7 @@ Set to `true` to enable a dark mode for the {kib} UI. You must refresh the page to apply the setting. [[theme-version]]`theme:version`:: -Specifies the {kib} theme. If you change the setting, refresh the page to apply the setting. +Specifies the {kib} theme. If you change the setting, refresh the page to apply the setting. [[timepicker-quickranges]]`timepicker:quickRanges`:: The list of ranges to show in the Quick section of the time filter. This should @@ -214,7 +214,7 @@ truncation. When enabled, provides access to the experimental *Labs* features for *Canvas*. [[labs-dashboard-defer-below-fold]]`labs:dashboard:deferBelowFold`:: -When enabled, the panels that appear below the fold are loaded when they become visible on the dashboard. +When enabled, the panels that appear below the fold are loaded when they become visible on the dashboard. _Below the fold_ refers to panels that are not immediately visible when you open a dashboard, but become visible as you scroll. For additional information, refer to <>. [[labs-dashboard-enable-ui]]`labs:dashboard:enable_ui`:: @@ -240,7 +240,7 @@ Banners are a https://www.elastic.co/subscriptions[subscription feature]. [horizontal] [[banners-placement]]`banners:placement`:: -Set to `Top` to display a banner above the Elastic header for this space. Defaults to the value of +Set to `Top` to display a banner above the Elastic header for this space. Defaults to the value of the `xpack.banners.placement` configuration property. [[banners-textcontent]]`banners:textContent`:: @@ -443,6 +443,9 @@ The threshold above which {ml} job anomalies are displayed in the {security-app} A comma-delimited list of {es} indices from which the {security-app} collects events. +[[securitysolution-threatindices]]`securitySolution:defaultThreatIndex`:: +A comma-delimited list of Threat Intelligence indices from which the {security-app} collects indicators. + [[securitysolution-enablenewsfeed]]`securitySolution:enableNewsFeed`:: Enables the security news feed on the Security *Overview* page. @@ -544,4 +547,4 @@ only production-ready visualizations are available to users. [horizontal] [[telemetry-enabled-advanced-setting]]`telemetry:enabled`:: When enabled, helps improve the Elastic Stack by providing usage statistics for -basic features. This data will not be shared outside of Elastic. \ No newline at end of file +basic features. This data will not be shared outside of Elastic. diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index b339daf3d36f7..694f8c53f6745 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -281,16 +281,15 @@ NOTE: This setting exists for backwards compatibility, but is unused and hardcod [[reporting-advanced-settings]] ==== Security settings -[[xpack-reporting-roles-enabled]] `xpack.reporting.roles.enabled`:: -deprecated:[7.14.0,This setting must be set to `false` in 8.0.] When `true`, grants users access to the {report-features} by assigning reporting roles, specified by `xpack.reporting.roles.allow`. Granting access to users this way is deprecated. Set to `false` and use {kibana-ref}/kibana-privileges.html[{kib} privileges] instead. Defaults to `true`. +With Security enabled, Reporting has two forms of access control: each user can only access their own reports, and custom roles determine who has privilege to generate reports. When Reporting is configured with <>, you can control the spaces and applications where users are allowed to generate reports. [NOTE] ============================================================================ -In 7.x, the default value of `xpack.reporting.roles.enabled` is `true`. To migrate users to the -new method of securing access to *Reporting*, you must set `xpack.reporting.roles.enabled: false`. In the next major version of {kib}, `false` will be the only valid configuration. +The `xpack.reporting.roles` settings are for a deprecated system of access control in Reporting. It does not allow API Keys to generate reports, and it doesn't allow {kib} application privileges. We recommend you explicitly turn off reporting's deprecated access control feature by adding `xpack.reporting.roles.enabled: false` in kibana.yml. This will enable you to create custom roles that provide application privileges for reporting, as described in <>. ============================================================================ -`xpack.reporting.roles.allow`:: -deprecated:[7.14.0,This setting will be removed in 8.0.] Specifies the roles, in addition to superusers, that can generate reports, using the {ref}/security-api.html#security-role-apis[{es} role management APIs]. Requires `xpack.reporting.roles.enabled` to be `true`. Granting access to users this way is deprecated. Use {kibana-ref}/kibana-privileges.html[{kib} privileges] instead. Defaults to `[ "reporting_user" ]`. +[[xpack-reporting-roles-enabled]] `xpack.reporting.roles.enabled`:: +deprecated:[7.14.0,The default for this setting will be `false` in an upcoming version of {kib}.] Sets access control to a set of assigned reporting roles, specified by `xpack.reporting.roles.allow`. Defaults to `true`. -NOTE: Each user has access to only their own reports. +`xpack.reporting.roles.allow`:: +deprecated:[7.14.0] In addition to superusers, specifies the roles that can generate reports using the {ref}/security-api.html#security-role-apis[{es} role management APIs]. Requires `xpack.reporting.roles.enabled` to be `true`. Defaults to `[ "reporting_user" ]`. diff --git a/docs/setup/configuring-reporting.asciidoc b/docs/setup/configuring-reporting.asciidoc index 0dba7befa2931..6d209092d3338 100644 --- a/docs/setup/configuring-reporting.asciidoc +++ b/docs/setup/configuring-reporting.asciidoc @@ -41,11 +41,16 @@ To troubleshoot the problem, start the {kib} server with environment variables t [float] [[grant-user-access]] === Grant users access to reporting +When security is enabled, you grant users access to generate reports with <>, which allow you to create custom roles that control the spaces and applications where users generate reports. -When security is enabled, access to the {report-features} is controlled by roles and <>. With privileges, you can define custom roles that grant *Reporting* privileges as sub-features of {kib} applications. To grant users permission to generate reports and view their reports in *Reporting*, create and assign the reporting role. - -[[reporting-app-users]] -NOTE: In 7.12.0 and earlier, you grant access to the {report-features} by assigning users the `reporting_user` role in {es}. +. Enable application privileges in Reporting. To enable, turn off the default user access control features in `kibana.yml`: ++ +[source,yaml] +------------------------------------ +xpack.reporting.roles.enabled: false +------------------------------------ ++ +NOTE: If you use the default settings, you can still create a custom role that grants reporting privileges. The default role is `reporting_user`. This behavior is being deprecated and does not allow application-level access controls for {report-features}, and does not allow API keys or authentication tokens to authorize report generation. Refer to <> for information and caveats about the deprecated access control features. . Create the reporting role. @@ -90,10 +95,12 @@ If the *Reporting* option is unavailable, contact your administrator, or < Reporting*. Users can only access their own reports. + [float] [[reporting-roles-user-api]] ==== Grant access with the role API -You can also use the {ref}/security-api-put-role.html[role API] to grant access to the reporting features. Grant the reporting role to users in combination with other roles that grant read access to the data in {es}, and at least read access in the applications where users can generate reports. +With <> enabled in Reporting, you can also use the {ref}/security-api-put-role.html[role API] to grant access to the {report-features}. Grant custom reporting roles to users in combination with other roles that grant read access to the data in {es}, and at least read access in the applications where users can generate reports. [source, sh] --------------------------------------------------------------- diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index ac50062470d78..203339be638ab 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -406,7 +406,10 @@ override this parameter to use their own Tile Map Service. For example: `"https://tiles.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana"` | `migrations.batchSize:` - | Defines the number of documents migrated at a time. The higher the value, the faster the Saved Objects migration process performs at the cost of higher memory consumption. If the migration fails due to a `circuit_breaking_exception`, set a smaller `batchSize` value. *Default: `1000`* + | Defines the number of documents migrated at a time. The higher the value, the faster the Saved Objects migration process performs at the cost of higher memory consumption. If upgrade migrations results in {kib} crashing with an out of memory exception or fails due to an Elasticsearch `circuit_breaking_exception`, use a smaller `batchSize` value to reduce the memory pressure. *Default: `1000`* + + | `migrations.maxBatchSizeBytes:` + | Defines the maximum payload size for indexing batches of upgraded saved objects to avoid migrations failing due to a 413 Request Entity Too Large response from Elasticsearch. This value should be lower than or equal to your Elasticsearch cluster's `http.max_content_length` configuration option. *Default: `100mb`* | `migrations.enableV2:` | experimental[]. Enables the new Saved Objects migration algorithm. For information about the migration algorithm, refer to <>. When `migrations v2` is stable, the setting will be removed in an upcoming release without any further notice. Setting the value to `false` causes {kib} to use the legacy migration algorithm, which shipped in 7.11 and earlier versions. *Default: `true`* diff --git a/docs/user/discover.asciidoc b/docs/user/discover.asciidoc index 1e716a840095d..e52531f9decdc 100644 --- a/docs/user/discover.asciidoc +++ b/docs/user/discover.asciidoc @@ -78,7 +78,7 @@ If you are using the sample data, this value was set when you added the data. If you are using your own data, and it does not have a time field, the range selection is not available. . To view the count of documents for a given time in the specified range, -click and drag the mouse over the histogram. +click and drag the mouse over the chart. [float] [[explore-fields-in-your-data]] @@ -108,7 +108,7 @@ them to your document table. Your table should look similar to this: image:images/document-table.png[Document table with fields for manufacturer, customer_first_name, and customer_last_name] . To rearrange the table columns, hover the mouse over a -column header, and then use the move controls. +column header, and then use the move control. . To view more of the document table, click *Hide chart*. @@ -275,7 +275,7 @@ image:images/discover-maps.png[Map containing documents] [[share-your-findings]] === Share your findings -To share your findings with a larger audience, click *Share* in the toolbar. For detailed information about the sharing options, refer to <>. +To share your findings with a larger audience, click *Share* in the *Discover* toolbar. For detailed information about the sharing options, refer to <>. [float] @@ -285,8 +285,6 @@ To share your findings with a larger audience, click *Share* in the toolbar. For * <>. -* <>. - * <> to better meet your needs. Go to **Advanced Settings** to configure the number of documents to show, the table columns that display by default, and more. diff --git a/package.json b/package.json index 836e5336b7b50..e603190c72698 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "**/underscore": "^1.13.1" }, "engines": { - "node": "14.17.5", + "node": "14.17.6", "yarn": "^1.21.1" }, "dependencies": { @@ -100,7 +100,7 @@ "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.19", "@elastic/ems-client": "7.15.0", - "@elastic/eui": "37.3.0", + "@elastic/eui": "37.3.1", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/maki": "6.3.0", @@ -655,6 +655,7 @@ "@types/yauzl": "^2.9.1", "@types/zen-observable": "^0.8.0", "@typescript-eslint/eslint-plugin": "^4.14.1", + "@typescript-eslint/typescript-estree": "^4.14.1", "@typescript-eslint/parser": "^4.14.1", "@yarnpkg/lockfile": "^1.1.0", "abab": "^2.0.4", @@ -725,6 +726,7 @@ "eslint-plugin-react": "^7.20.3", "eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-react-perf": "^3.2.3", + "eslint-traverse": "^1.0.0", "expose-loader": "^0.7.5", "faker": "^5.1.0", "fancy-log": "^1.3.2", diff --git a/packages/elastic-eslint-config-kibana/.eslintrc.js b/packages/elastic-eslint-config-kibana/.eslintrc.js index 1b3e852e5a502..38c0c43132564 100644 --- a/packages/elastic-eslint-config-kibana/.eslintrc.js +++ b/packages/elastic-eslint-config-kibana/.eslintrc.js @@ -90,5 +90,7 @@ module.exports = { }, ], ], + + '@kbn/eslint/no_async_promise_body': 'error', }, }; diff --git a/packages/kbn-config/BUILD.bazel b/packages/kbn-config/BUILD.bazel index 75e4428ed2d70..e0cf4d2205d65 100644 --- a/packages/kbn-config/BUILD.bazel +++ b/packages/kbn-config/BUILD.bazel @@ -35,6 +35,7 @@ RUNTIME_DEPS = [ "//packages/kbn-logging", "//packages/kbn-std", "//packages/kbn-utility-types", + "//packages/kbn-i18n", "@npm//js-yaml", "@npm//load-json-file", "@npm//lodash", @@ -48,6 +49,7 @@ TYPES_DEPS = [ "//packages/kbn-logging", "//packages/kbn-std", "//packages/kbn-utility-types", + "//packages/kbn-i18n", "@npm//load-json-file", "@npm//rxjs", "@npm//@types/jest", diff --git a/packages/kbn-config/src/deprecation/deprecation_factory.test.ts b/packages/kbn-config/src/deprecation/deprecation_factory.test.ts index 563d4017f5ed9..0a605cbc1c532 100644 --- a/packages/kbn-config/src/deprecation/deprecation_factory.test.ts +++ b/packages/kbn-config/src/deprecation/deprecation_factory.test.ts @@ -48,7 +48,8 @@ describe('DeprecationFactory', () => { "Replace \\"myplugin.deprecated\\" with \\"myplugin.renamed\\" in the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, - "message": "\\"myplugin.deprecated\\" is deprecated and has been replaced by \\"myplugin.renamed\\"", + "message": "Setting \\"myplugin.deprecated\\" has been replaced by \\"myplugin.renamed\\"", + "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, ], ] @@ -103,7 +104,8 @@ describe('DeprecationFactory', () => { "Replace \\"myplugin.oldsection.deprecated\\" with \\"myplugin.newsection.renamed\\" in the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, - "message": "\\"myplugin.oldsection.deprecated\\" is deprecated and has been replaced by \\"myplugin.newsection.renamed\\"", + "message": "Setting \\"myplugin.oldsection.deprecated\\" has been replaced by \\"myplugin.newsection.renamed\\"", + "title": "Setting \\"myplugin.oldsection.deprecated\\" is deprecated", }, ], ] @@ -130,7 +132,8 @@ describe('DeprecationFactory', () => { "Remove \\"myplugin.deprecated\\" from the config.", ], }, - "message": "\\"myplugin.deprecated\\" is deprecated and has been replaced by \\"myplugin.renamed\\". However both key are present, ignoring \\"myplugin.deprecated\\"", + "message": "Setting \\"$myplugin.deprecated\\" has been replaced by \\"$myplugin.renamed\\". However, both keys are present. Ignoring \\"$myplugin.deprecated\\"", + "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, ], ] @@ -172,7 +175,8 @@ describe('DeprecationFactory', () => { "Replace \\"myplugin.deprecated\\" with \\"myplugin.renamed\\" in the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, - "message": "\\"myplugin.deprecated\\" is deprecated and has been replaced by \\"myplugin.renamed\\"", + "message": "Setting \\"myplugin.deprecated\\" has been replaced by \\"myplugin.renamed\\"", + "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, ], ] @@ -212,7 +216,8 @@ describe('DeprecationFactory', () => { "Replace \\"oldplugin.deprecated\\" with \\"newplugin.renamed\\" in the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, - "message": "\\"oldplugin.deprecated\\" is deprecated and has been replaced by \\"newplugin.renamed\\"", + "message": "Setting \\"oldplugin.deprecated\\" has been replaced by \\"newplugin.renamed\\"", + "title": "Setting \\"oldplugin.deprecated\\" is deprecated", }, ], ] @@ -264,7 +269,8 @@ describe('DeprecationFactory', () => { "Remove \\"myplugin.deprecated\\" from the config.", ], }, - "message": "\\"myplugin.deprecated\\" is deprecated and has been replaced by \\"myplugin.renamed\\". However both key are present, ignoring \\"myplugin.deprecated\\"", + "message": "Setting \\"$myplugin.deprecated\\" has been replaced by \\"$myplugin.renamed\\". However, both keys are present. Ignoring \\"$myplugin.deprecated\\"", + "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, ], ] @@ -293,10 +299,11 @@ describe('DeprecationFactory', () => { Object { "correctiveActions": Object { "manualSteps": Array [ - "Remove \\"myplugin.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only)", + "Remove \\"myplugin.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, - "message": "myplugin.deprecated is deprecated and is no longer used", + "message": "You no longer need to configure \\"myplugin.deprecated\\".", + "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, ], ] @@ -325,10 +332,11 @@ describe('DeprecationFactory', () => { Object { "correctiveActions": Object { "manualSteps": Array [ - "Remove \\"myplugin.section.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only)", + "Remove \\"myplugin.section.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, - "message": "myplugin.section.deprecated is deprecated and is no longer used", + "message": "You no longer need to configure \\"myplugin.section.deprecated\\".", + "title": "Setting \\"myplugin.section.deprecated\\" is deprecated", }, ], ] @@ -375,10 +383,11 @@ describe('DeprecationFactory', () => { Object { "correctiveActions": Object { "manualSteps": Array [ - "Remove \\"myplugin.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only)", + "Remove \\"myplugin.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, - "message": "myplugin.deprecated is deprecated and is no longer used", + "message": "You no longer need to configure \\"myplugin.deprecated\\".", + "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, ], ] diff --git a/packages/kbn-config/src/deprecation/deprecation_factory.ts b/packages/kbn-config/src/deprecation/deprecation_factory.ts index 76bcc1958d0de..6d7669cef04f2 100644 --- a/packages/kbn-config/src/deprecation/deprecation_factory.ts +++ b/packages/kbn-config/src/deprecation/deprecation_factory.ts @@ -7,6 +7,8 @@ */ import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; + import { ConfigDeprecation, AddConfigDeprecation, @@ -15,6 +17,13 @@ import { ConfigDeprecationCommand, } from './types'; +const getDeprecationTitle = (deprecationPath: string) => { + return i18n.translate('kbnConfig.deprecations.deprecatedSettingTitle', { + defaultMessage: 'Setting "{deprecationPath}" is deprecated', + values: { deprecationPath }, + }); +}; + const _rename = ( config: Record, rootPath: string, @@ -33,10 +42,18 @@ const _rename = ( const newValue = get(config, fullNewPath); if (newValue === undefined) { addDeprecation({ - message: `"${fullOldPath}" is deprecated and has been replaced by "${fullNewPath}"`, + title: getDeprecationTitle(fullOldPath), + message: i18n.translate('kbnConfig.deprecations.replacedSettingMessage', { + defaultMessage: `Setting "{fullOldPath}" has been replaced by "{fullNewPath}"`, + values: { fullOldPath, fullNewPath }, + }), correctiveActions: { manualSteps: [ - `Replace "${fullOldPath}" with "${fullNewPath}" in the Kibana config file, CLI flag, or environment variable (in Docker only).`, + i18n.translate('kbnConfig.deprecations.replacedSetting.manualStepOneMessage', { + defaultMessage: + 'Replace "{fullOldPath}" with "{fullNewPath}" in the Kibana config file, CLI flag, or environment variable (in Docker only).', + values: { fullOldPath, fullNewPath }, + }), ], }, ...details, @@ -47,11 +64,23 @@ const _rename = ( }; } else { addDeprecation({ - message: `"${fullOldPath}" is deprecated and has been replaced by "${fullNewPath}". However both key are present, ignoring "${fullOldPath}"`, + title: getDeprecationTitle(fullOldPath), + message: i18n.translate('kbnConfig.deprecations.conflictSettingMessage', { + defaultMessage: + 'Setting "${fullOldPath}" has been replaced by "${fullNewPath}". However, both keys are present. Ignoring "${fullOldPath}"', + values: { fullOldPath, fullNewPath }, + }), correctiveActions: { manualSteps: [ - `Make sure "${fullNewPath}" contains the correct value in the config file, CLI flag, or environment variable (in Docker only).`, - `Remove "${fullOldPath}" from the config.`, + i18n.translate('kbnConfig.deprecations.conflictSetting.manualStepOneMessage', { + defaultMessage: + 'Make sure "{fullNewPath}" contains the correct value in the config file, CLI flag, or environment variable (in Docker only).', + values: { fullNewPath }, + }), + i18n.translate('kbnConfig.deprecations.conflictSetting.manualStepTwoMessage', { + defaultMessage: 'Remove "{fullOldPath}" from the config.', + values: { fullOldPath }, + }), ], }, ...details, @@ -75,10 +104,18 @@ const _unused = ( return; } addDeprecation({ - message: `${fullPath} is deprecated and is no longer used`, + title: getDeprecationTitle(fullPath), + message: i18n.translate('kbnConfig.deprecations.unusedSettingMessage', { + defaultMessage: 'You no longer need to configure "{fullPath}".', + values: { fullPath }, + }), correctiveActions: { manualSteps: [ - `Remove "${fullPath}" from the Kibana config file, CLI flag, or environment variable (in Docker only)`, + i18n.translate('kbnConfig.deprecations.unusedSetting.manualStepOneMessage', { + defaultMessage: + 'Remove "{fullPath}" from the Kibana config file, CLI flag, or environment variable (in Docker only).', + values: { fullPath }, + }), ], }, ...details, diff --git a/packages/kbn-config/src/deprecation/types.ts b/packages/kbn-config/src/deprecation/types.ts index 1791dac060e2b..007c3ec54113b 100644 --- a/packages/kbn-config/src/deprecation/types.ts +++ b/packages/kbn-config/src/deprecation/types.ts @@ -19,6 +19,8 @@ export type AddConfigDeprecation = (details: DeprecatedConfigDetails) => void; * @public */ export interface DeprecatedConfigDetails { + /* The title to be displayed for the deprecation. */ + title?: string; /* The message to be displayed for the deprecation. */ message: string; /* (optional) set false to prevent the config service from logging the deprecation message. */ diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts index 7847cad0fd5e7..0584ee27aa5f6 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts @@ -221,11 +221,12 @@ export class CiStatsReporter { ? `${error.response.status} response` : 'no response'; + const seconds = attempt * 10; this.log.warning( - `failed to reach ci-stats service [reason=${reason}], retrying in ${attempt} seconds` + `failed to reach ci-stats service, retrying in ${seconds} seconds, [reason=${reason}], [error=${error.message}]` ); - await new Promise((resolve) => setTimeout(resolve, attempt * 1000)); + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); } } } diff --git a/packages/kbn-eslint-plugin-eslint/BUILD.bazel b/packages/kbn-eslint-plugin-eslint/BUILD.bazel index 0ea6a4a80be06..2677e88927ab3 100644 --- a/packages/kbn-eslint-plugin-eslint/BUILD.bazel +++ b/packages/kbn-eslint-plugin-eslint/BUILD.bazel @@ -6,6 +6,7 @@ PKG_REQUIRE_NAME = "@kbn/eslint-plugin-eslint" SOURCE_FILES = glob( [ "rules/**/*.js", + "helpers/**/*.js", "index.js", "lib.js", ], diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/index.ts b/packages/kbn-eslint-plugin-eslint/__fixtures__/bar.ts similarity index 84% rename from src/plugins/discover/public/application/angular/context/components/action_bar/index.ts rename to packages/kbn-eslint-plugin-eslint/__fixtures__/bar.ts index 6e09466f6a3ec..16c40fc9bfd7d 100644 --- a/src/plugins/discover/public/application/angular/context/components/action_bar/index.ts +++ b/packages/kbn-eslint-plugin-eslint/__fixtures__/bar.ts @@ -6,4 +6,6 @@ * Side Public License, v 1. */ -import './action_bar_directive'; +/* eslint-disable no-restricted-syntax */ + +export class Bar {} diff --git a/src/plugins/discover/public/application/angular/helpers/index.ts b/packages/kbn-eslint-plugin-eslint/__fixtures__/baz.ts similarity index 76% rename from src/plugins/discover/public/application/angular/helpers/index.ts rename to packages/kbn-eslint-plugin-eslint/__fixtures__/baz.ts index a7d9d4581d989..5ee1d85aa253d 100644 --- a/src/plugins/discover/public/application/angular/helpers/index.ts +++ b/packages/kbn-eslint-plugin-eslint/__fixtures__/baz.ts @@ -6,5 +6,8 @@ * Side Public License, v 1. */ -export { handleSourceColumnState } from './state_helpers'; -export { PromiseServiceCreator } from './promises'; +/* eslint-disable no-restricted-syntax */ + +export const one = 1; +export const two = 2; +export const three = 3; diff --git a/packages/kbn-eslint-plugin-eslint/__fixtures__/foo.ts b/packages/kbn-eslint-plugin-eslint/__fixtures__/foo.ts new file mode 100644 index 0000000000000..f97ed79d95437 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/__fixtures__/foo.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +/* eslint-disable no-restricted-syntax */ + +export type { Bar as ReexportedClass } from './bar'; + +export const someConst = 'bar'; + +// eslint-disable-next-line prefer-const +export let someLet = 'bar'; + +export function someFunction() {} + +export class SomeClass {} + +export interface SomeInterface { + prop: number; +} + +export enum SomeEnum { + a = 'a', + b = 'b', +} + +export type TypeAlias = string[]; diff --git a/src/plugins/discover/public/application/angular/index.ts b/packages/kbn-eslint-plugin-eslint/__fixtures__/top.ts similarity index 78% rename from src/plugins/discover/public/application/angular/index.ts rename to packages/kbn-eslint-plugin-eslint/__fixtures__/top.ts index 643823a15ffcd..bf924e011053c 100644 --- a/src/plugins/discover/public/application/angular/index.ts +++ b/packages/kbn-eslint-plugin-eslint/__fixtures__/top.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -// required for i18nIdDirective -import 'angular-sanitize'; +/* eslint-disable no-restricted-syntax */ -import './doc'; -import './context'; +export * from './foo'; diff --git a/packages/kbn-eslint-plugin-eslint/helpers/codegen.js b/packages/kbn-eslint-plugin-eslint/helpers/codegen.js new file mode 100644 index 0000000000000..e55a946e8ad19 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/helpers/codegen.js @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const t = require('@babel/types'); +const { default: generate } = require('@babel/generator'); + +/** @typedef {import('./export_set').ExportSet} ExportSet */ + +/** + * Generate code for replacing a `export * from './path'`, ie. + * + * export type { foo } from './path' + * export { bar } from './path' + + * @param {ExportSet} exportSet + * @param {string} source + */ +const getExportCode = (exportSet, source) => { + const exportedTypes = exportSet.types.size + ? t.exportNamedDeclaration( + undefined, + Array.from(exportSet.types).map((n) => t.exportSpecifier(t.identifier(n), t.identifier(n))), + t.stringLiteral(source) + ) + : undefined; + + if (exportedTypes) { + exportedTypes.exportKind = 'type'; + } + + const exportedValues = exportSet.values.size + ? t.exportNamedDeclaration( + undefined, + Array.from(exportSet.values).map((n) => + t.exportSpecifier(t.identifier(n), t.identifier(n)) + ), + t.stringLiteral(source) + ) + : undefined; + + return generate(t.program([exportedTypes, exportedValues].filter(Boolean))).code; +}; + +/** + * Generate code for replacing a `export * as name from './path'`, ie. + * + * import { foo, bar } from './path' + * export const name = { foo, bar } + * + * @param {string} nsName + * @param {string[]} exportNames + * @param {string} source + */ +const getExportNamedNamespaceCode = (nsName, exportNames, source) => { + return generate( + t.program([ + t.importDeclaration( + exportNames.map((n) => t.importSpecifier(t.identifier(n), t.identifier(n))), + t.stringLiteral(source) + ), + t.exportNamedDeclaration( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(nsName), + t.objectExpression( + exportNames.map((n) => + t.objectProperty(t.identifier(n), t.identifier(n), false, true) + ) + ) + ), + ]) + ), + ]) + ).code; +}; + +module.exports = { getExportCode, getExportNamedNamespaceCode }; diff --git a/packages/kbn-eslint-plugin-eslint/helpers/export_set.js b/packages/kbn-eslint-plugin-eslint/helpers/export_set.js new file mode 100644 index 0000000000000..fb1b24a34878c --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/helpers/export_set.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +/** + * Helper class to collect exports of different types, either "value" exports or "type" exports + */ +class ExportSet { + constructor() { + /** @type {Set} */ + this.values = new Set(); + + /** @type {Set} */ + this.types = new Set(); + } + + get size() { + return this.values.size + this.types.size; + } + + /** + * @param {'value'|'type'} type + * @param {string} value + */ + add(type, value) { + this[type + 's'].add(value); + } +} + +module.exports = { ExportSet }; diff --git a/packages/kbn-eslint-plugin-eslint/helpers/exports.js b/packages/kbn-eslint-plugin-eslint/helpers/exports.js new file mode 100644 index 0000000000000..b7af8e83d7661 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/helpers/exports.js @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const Fs = require('fs'); +const Path = require('path'); +const ts = require('typescript'); +const { REPO_ROOT } = require('@kbn/dev-utils'); +const { ExportSet } = require('./export_set'); + +/** @typedef {import("@typescript-eslint/types").TSESTree.ExportAllDeclaration} ExportAllDeclaration */ +/** @typedef {import("estree").Node} Node */ +/** @typedef {(path: string) => ts.SourceFile} Parser */ +/** @typedef {ts.Identifier|ts.BindingName} ExportNameNode */ + +const RESOLUTION_EXTENSIONS = ['.js', '.json', '.ts', '.tsx', '.d.ts']; + +/** @param {ts.Statement} node */ +const hasExportMod = (node) => node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); + +/** @param {string} path */ +const safeStat = (path) => { + try { + return Fs.statSync(path); + } catch (error) { + if (error.code === 'ENOENT') { + return undefined; + } + throw error; + } +}; + +/** + * @param {string} dir + * @param {string} specifier + * @returns {string|undefined} + */ +const normalizeRelativeSpecifier = (dir, specifier) => { + if (specifier.startsWith('src/') || specifier.startsWith('x-pack/')) { + return Path.resolve(REPO_ROOT, specifier); + } + if (specifier.startsWith('.')) { + return Path.resolve(dir, specifier); + } +}; + +/** + * @param {string} basePath + * @returns {string | undefined} + */ +const checkExtensions = (basePath) => { + for (const ext of RESOLUTION_EXTENSIONS) { + const withExt = `${basePath}${ext}`; + const stats = safeStat(withExt); + if (stats?.isFile()) { + return withExt; + } + } +}; + +/** + * @param {string} dir + * @param {string} specifier + * @returns {string|undefined} + */ +const getImportPath = (dir, specifier) => { + const base = normalizeRelativeSpecifier(dir, specifier); + if (!specifier) { + return undefined; + } + + const noExt = safeStat(base); + if (noExt && noExt.isFile()) { + return base; + } + + if (noExt && noExt.isDirectory()) { + return checkExtensions(Path.resolve(base, 'index')); + } + + if (Path.extname(base) !== '') { + return; + } + + return checkExtensions(base); +}; + +/** + * Recursively traverse from a file path to collect all the exported values/types + * from the file. Returns an ExportSet which groups the exports by type, either + * "value" or "type" exports. + * + * @param {Parser} parser + * @param {string} from + * @param {ts.ExportDeclaration} exportFrom + * @param {ExportSet | undefined} exportSet only passed when called recursively + * @param {boolean | undefined} assumeAllTypes only passed when called recursively + * @returns {ExportSet | undefined} + */ +const getExportNamesDeep = ( + parser, + from, + exportFrom, + exportSet = new ExportSet(), + assumeAllTypes = false +) => { + const specifier = ts.isStringLiteral(exportFrom.moduleSpecifier) + ? exportFrom.moduleSpecifier.text + : undefined; + + if (!specifier) { + return undefined; + } + + const importPath = getImportPath(Path.dirname(from), specifier); + if (!importPath) { + return undefined; + } + + const sourceFile = parser(importPath); + + for (const statement of sourceFile.statements) { + // export function xyz() ... + if (ts.isFunctionDeclaration(statement) && statement.name && hasExportMod(statement)) { + exportSet.add(assumeAllTypes ? 'type' : 'value', statement.name.getText()); + continue; + } + + // export const/let foo = ... + if (ts.isVariableStatement(statement) && hasExportMod(statement)) { + for (const dec of statement.declarationList.declarations) { + exportSet.add(assumeAllTypes ? 'type' : 'value', dec.name.getText()); + } + continue; + } + + // export class xyc + if (ts.isClassDeclaration(statement) && statement.name && hasExportMod(statement)) { + exportSet.add(assumeAllTypes ? 'type' : 'value', statement.name.getText()); + continue; + } + + // export interface Foo {...} + if (ts.isInterfaceDeclaration(statement) && hasExportMod(statement)) { + exportSet.add('type', statement.name.getText()); + continue; + } + + // export type Foo = ... + if (ts.isTypeAliasDeclaration(statement) && hasExportMod(statement)) { + exportSet.add('type', statement.name.getText()); + continue; + } + + // export enum ... + if (ts.isEnumDeclaration(statement) && hasExportMod(statement)) { + exportSet.add(assumeAllTypes ? 'type' : 'value', statement.name.getText()); + continue; + } + + if (ts.isExportDeclaration(statement)) { + const clause = statement.exportClause; + const types = assumeAllTypes || statement.isTypeOnly; + + // export * from '../foo'; + if (!clause) { + const childTypes = getExportNamesDeep( + parser, + sourceFile.fileName, + statement, + exportSet, + types + ); + + if (!childTypes) { + // abort if we can't get all the exported names + return undefined; + } + + continue; + } + + // export * as foo from './foo' + if (ts.isNamespaceExport(clause)) { + exportSet.add(types ? 'type' : 'value', clause.name.getText()); + continue; + } + + // export { foo } + // export { foo as x } from 'other' + // export { default as foo } from 'other' + for (const e of clause.elements) { + exportSet.add(types ? 'type' : 'value', e.name.getText()); + } + continue; + } + } + + return exportSet; +}; + +module.exports = { getExportNamesDeep }; diff --git a/packages/kbn-eslint-plugin-eslint/index.js b/packages/kbn-eslint-plugin-eslint/index.js index e5a38e5f09529..cf96cd9e801ba 100644 --- a/packages/kbn-eslint-plugin-eslint/index.js +++ b/packages/kbn-eslint-plugin-eslint/index.js @@ -12,5 +12,7 @@ module.exports = { 'disallow-license-headers': require('./rules/disallow_license_headers'), 'no-restricted-paths': require('./rules/no_restricted_paths'), module_migration: require('./rules/module_migration'), + no_export_all: require('./rules/no_export_all'), + no_async_promise_body: require('./rules/no_async_promise_body'), }, }; diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_async_promise_body.js b/packages/kbn-eslint-plugin-eslint/rules/no_async_promise_body.js new file mode 100644 index 0000000000000..317758fd3629a --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_async_promise_body.js @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const { parseExpression } = require('@babel/parser'); +const { default: generate } = require('@babel/generator'); +const tsEstree = require('@typescript-eslint/typescript-estree'); +const traverse = require('eslint-traverse'); +const esTypes = tsEstree.AST_NODE_TYPES; +const babelTypes = require('@babel/types'); + +/** @typedef {import("eslint").Rule.RuleModule} Rule */ +/** @typedef {import("@typescript-eslint/parser").ParserServices} ParserServices */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.Expression} Expression */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.ArrowFunctionExpression} ArrowFunctionExpression */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.FunctionExpression} FunctionExpression */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.TryStatement} TryStatement */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.NewExpression} NewExpression */ +/** @typedef {import("typescript").ExportDeclaration} ExportDeclaration */ +/** @typedef {import("eslint").Rule.RuleFixer} Fixer */ + +const ERROR_MSG = + 'Passing an async function to the Promise constructor leads to a hidden promise being created and prevents handling rejections'; + +/** + * @param {Expression} node + */ +const isPromise = (node) => node.type === esTypes.Identifier && node.name === 'Promise'; + +/** + * @param {Expression} node + * @returns {node is ArrowFunctionExpression | FunctionExpression} + */ +const isFunc = (node) => + node.type === esTypes.ArrowFunctionExpression || node.type === esTypes.FunctionExpression; + +/** + * @param {any} context + * @param {ArrowFunctionExpression | FunctionExpression} node + */ +const isFuncBodySafe = (context, node) => { + // if the body isn't wrapped in a blockStatement it can't have a try/catch at the root + if (node.body.type !== esTypes.BlockStatement) { + return false; + } + + // when the entire body is wrapped in a try/catch it is the only node + if (node.body.body.length !== 1) { + return false; + } + + const tryNode = node.body.body[0]; + // ensure we have a try node with a handler + if (tryNode.type !== esTypes.TryStatement || !tryNode.handler) { + return false; + } + + // ensure the handler doesn't throw + let hasThrow = false; + traverse(context, tryNode.handler, (path) => { + if (path.node.type === esTypes.ThrowStatement) { + hasThrow = true; + return traverse.STOP; + } + }); + return !hasThrow; +}; + +/** + * @param {string} code + */ +const wrapFunctionInTryCatch = (code) => { + // parse the code with babel so we can mutate the AST + const ast = parseExpression(code, { + plugins: ['typescript', 'jsx'], + }); + + // validate that the code reperesents an arrow or function expression + if (!babelTypes.isArrowFunctionExpression(ast) && !babelTypes.isFunctionExpression(ast)) { + throw new Error('expected function to be an arrow or function expression'); + } + + // ensure that the function receives the second argument, and capture its name if already defined + let rejectName = 'reject'; + if (ast.params.length === 0) { + ast.params.push(babelTypes.identifier('resolve'), babelTypes.identifier(rejectName)); + } else if (ast.params.length === 1) { + ast.params.push(babelTypes.identifier(rejectName)); + } else if (ast.params.length === 2) { + if (babelTypes.isIdentifier(ast.params[1])) { + rejectName = ast.params[1].name; + } else { + throw new Error('expected second param of promise definition function to be an identifier'); + } + } + + // ensure that the body of the function is a blockStatement + let block = ast.body; + if (!babelTypes.isBlockStatement(block)) { + block = babelTypes.blockStatement([babelTypes.returnStatement(block)]); + } + + // redefine the body of the function as a new blockStatement containing a tryStatement + // which catches errors and forwards them to reject() when caught + ast.body = babelTypes.blockStatement([ + // try { + babelTypes.tryStatement( + block, + // catch (error) { + babelTypes.catchClause( + babelTypes.identifier('error'), + babelTypes.blockStatement([ + // reject(error) + babelTypes.expressionStatement( + babelTypes.callExpression(babelTypes.identifier(rejectName), [ + babelTypes.identifier('error'), + ]) + ), + ]) + ) + ), + ]); + + return generate(ast).code; +}; + +/** @type {Rule} */ +module.exports = { + meta: { + fixable: 'code', + schema: [], + }, + create: (context) => ({ + NewExpression(_) { + const node = /** @type {NewExpression} */ (_); + + // ensure we are newing up a promise with a single argument + if (!isPromise(node.callee) || node.arguments.length !== 1) { + return; + } + + const func = node.arguments[0]; + // ensure the argument is an arrow or function expression and is async + if (!isFunc(func) || !func.async) { + return; + } + + // body must be a blockStatement, try/catch can't exist outside of a block + if (!isFuncBodySafe(context, func)) { + context.report({ + message: ERROR_MSG, + loc: func.loc, + fix(fixer) { + const source = context.getSourceCode(); + return fixer.replaceText(func, wrapFunctionInTryCatch(source.getText(func))); + }, + }); + } + }, + }), +}; diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_async_promise_body.test.js b/packages/kbn-eslint-plugin-eslint/rules/no_async_promise_body.test.js new file mode 100644 index 0000000000000..f5929b1b3966f --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_async_promise_body.test.js @@ -0,0 +1,254 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const { RuleTester } = require('eslint'); +const rule = require('./no_async_promise_body'); +const dedent = require('dedent'); + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + ecmaFeatures: { + jsx: true, + }, + }, +}); + +ruleTester.run('@kbn/eslint/no_async_promise_body', rule, { + valid: [ + // caught but no resolve + { + code: dedent` + new Promise(async function (resolve) { + try { + await asyncOperation(); + } catch (error) { + // noop + } + }) + `, + }, + // arrow caught but no resolve + { + code: dedent` + new Promise(async (resolve) => { + try { + await asyncOperation(); + } catch (error) { + // noop + } + }) + `, + }, + // caught with reject + { + code: dedent` + new Promise(async function (resolve, reject) { + try { + await asyncOperation(); + } catch (error) { + reject(error) + } + }) + `, + }, + // arrow caught with reject + { + code: dedent` + new Promise(async (resolve, reject) => { + try { + await asyncOperation(); + } catch (error) { + reject(error) + } + }) + `, + }, + // non async + { + code: dedent` + new Promise(function (resolve) { + setTimeout(resolve, 10); + }) + `, + }, + // arrow non async + { + code: dedent` + new Promise((resolve) => setTimeout(resolve, 10)) + `, + }, + ], + + invalid: [ + // no catch + { + code: dedent` + new Promise(async function (resolve) { + const result = await asyncOperation(); + resolve(result); + }) + `, + errors: [ + { + line: 1, + message: + 'Passing an async function to the Promise constructor leads to a hidden promise being created and prevents handling rejections', + }, + ], + output: dedent` + new Promise(async function (resolve, reject) { + try { + const result = await asyncOperation(); + resolve(result); + } catch (error) { + reject(error); + } + }) + `, + }, + // arrow no catch + { + code: dedent` + new Promise(async (resolve) => { + const result = await asyncOperation(); + resolve(result); + }) + `, + errors: [ + { + line: 1, + message: + 'Passing an async function to the Promise constructor leads to a hidden promise being created and prevents handling rejections', + }, + ], + output: dedent` + new Promise(async (resolve, reject) => { + try { + const result = await asyncOperation(); + resolve(result); + } catch (error) { + reject(error); + } + }) + `, + }, + // catch, but it throws + { + code: dedent` + new Promise(async function (resolve) { + try { + const result = await asyncOperation(); + resolve(result); + } catch (error) { + if (error.code === 'foo') { + throw error; + } + } + }) + `, + errors: [ + { + line: 1, + message: + 'Passing an async function to the Promise constructor leads to a hidden promise being created and prevents handling rejections', + }, + ], + output: dedent` + new Promise(async function (resolve, reject) { + try { + try { + const result = await asyncOperation(); + resolve(result); + } catch (error) { + if (error.code === 'foo') { + throw error; + } + } + } catch (error) { + reject(error); + } + }) + `, + }, + // no catch without block + { + code: dedent` + new Promise(async (resolve) => resolve(await asyncOperation())); + `, + errors: [ + { + line: 1, + message: + 'Passing an async function to the Promise constructor leads to a hidden promise being created and prevents handling rejections', + }, + ], + output: dedent` + new Promise(async (resolve, reject) => { + try { + return resolve(await asyncOperation()); + } catch (error) { + reject(error); + } + }); + `, + }, + // no catch with named reject + { + code: dedent` + new Promise(async (resolve, rej) => { + const result = await asyncOperation(); + result ? resolve(true) : rej() + }); + `, + errors: [ + { + line: 1, + message: + 'Passing an async function to the Promise constructor leads to a hidden promise being created and prevents handling rejections', + }, + ], + output: dedent` + new Promise(async (resolve, rej) => { + try { + const result = await asyncOperation(); + result ? resolve(true) : rej(); + } catch (error) { + rej(error); + } + }); + `, + }, + // no catch with no args + { + code: dedent` + new Promise(async () => { + await asyncOperation(); + }); + `, + errors: [ + { + line: 1, + message: + 'Passing an async function to the Promise constructor leads to a hidden promise being created and prevents handling rejections', + }, + ], + output: dedent` + new Promise(async (resolve, reject) => { + try { + await asyncOperation(); + } catch (error) { + reject(error); + } + }); + `, + }, + ], +}); diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_export_all.js b/packages/kbn-eslint-plugin-eslint/rules/no_export_all.js new file mode 100644 index 0000000000000..e8d64b247c1a8 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_export_all.js @@ -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. + */ + +const Fs = require('fs'); +const ts = require('typescript'); +const { getExportCode, getExportNamedNamespaceCode } = require('../helpers/codegen'); +const tsEstree = require('@typescript-eslint/typescript-estree'); + +const { getExportNamesDeep } = require('../helpers/exports'); + +/** @typedef {import("eslint").Rule.RuleModule} Rule */ +/** @typedef {import("@typescript-eslint/parser").ParserServices} ParserServices */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.ExportAllDeclaration} EsTreeExportAllDeclaration */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.StringLiteral} EsTreeStringLiteral */ +/** @typedef {import("typescript").ExportDeclaration} ExportDeclaration */ +/** @typedef {import("../helpers/exports").Parser} Parser */ +/** @typedef {import("eslint").Rule.RuleFixer} Fixer */ + +const ERROR_MSG = + '`export *` is not allowed in the index files of plugins to prevent accidentally exporting too many APIs'; + +/** @type {Rule} */ +module.exports = { + meta: { + fixable: 'code', + schema: [], + }, + create: (context) => { + return { + ExportAllDeclaration(node) { + const services = /** @type ParserServices */ (context.parserServices); + const esNode = /** @type EsTreeExportAllDeclaration */ (node); + const tsnode = /** @type ExportDeclaration */ (services.esTreeNodeToTSNodeMap.get(esNode)); + + /** @type Parser */ + const parser = (path) => { + const code = Fs.readFileSync(path, 'utf-8'); + const result = tsEstree.parseAndGenerateServices(code, { + ...context.parserOptions, + comment: false, + filePath: path, + }); + return result.services.program.getSourceFile(path); + }; + + const exportSet = getExportNamesDeep(parser, context.getFilename(), tsnode); + const isTypeExport = esNode.exportKind === 'type'; + const isNamespaceExportWithTypes = + tsnode.exportClause && + ts.isNamespaceExport(tsnode.exportClause) && + (isTypeExport || exportSet.types.size); + + /** @param {Fixer} fixer */ + const fix = (fixer) => { + const source = /** @type EsTreeStringLiteral */ (esNode.source); + + if (tsnode.exportClause && ts.isNamespaceExport(tsnode.exportClause)) { + return fixer.replaceText( + node, + getExportNamedNamespaceCode( + tsnode.exportClause.name.getText(), + Array.from(exportSet.values), + source.value + ) + ); + } + + return fixer.replaceText(node, getExportCode(exportSet, source.value)); + }; + + context.report({ + message: ERROR_MSG, + loc: node.loc, + fix: exportSet?.size && !isNamespaceExportWithTypes ? fix : undefined, + }); + }, + }; + }, +}; diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_export_all.test.js b/packages/kbn-eslint-plugin-eslint/rules/no_export_all.test.js new file mode 100644 index 0000000000000..62a840cb8c91c --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_export_all.test.js @@ -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. + */ + +const Path = require('path'); + +const { RuleTester } = require('eslint'); +const dedent = require('dedent'); + +const rule = require('./no_export_all'); + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + ecmaFeatures: { + jsx: true, + }, + }, +}); + +ruleTester.run('@kbn/eslint/no_export_all', rule, { + valid: [ + { + code: dedent` + export { bar } from './foo'; + export { bar as box } from './foo'; + `, + }, + ], + + invalid: [ + { + filename: Path.resolve(__dirname, '../__fixtures__/index.ts'), + code: dedent` + export * as baz from './baz'; + export * from './foo'; + `, + + errors: [ + { + line: 1, + message: + '`export *` is not allowed in the index files of plugins to prevent accidentally exporting too many APIs', + }, + { + line: 2, + message: + '`export *` is not allowed in the index files of plugins to prevent accidentally exporting too many APIs', + }, + ], + + output: dedent` + import { one, two, three } from "./baz"; + export const baz = { + one, + two, + three + }; + export type { ReexportedClass, SomeInterface, TypeAlias } from "./foo"; + export { someConst, someLet, someFunction, SomeClass, SomeEnum } from "./foo"; + `, + }, + ], +}); diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index f0a95a612f02c..c7f4bbe253777 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -59656,8 +59656,9 @@ class CiStatsReporter { const reason = error !== null && error !== void 0 && (_error$response = error.response) !== null && _error$response !== void 0 && _error$response.status ? `${error.response.status} response` : 'no response'; - this.log.warning(`failed to reach ci-stats service [reason=${reason}], retrying in ${attempt} seconds`); - await new Promise(resolve => setTimeout(resolve, attempt * 1000)); + const seconds = attempt * 10; + this.log.warning(`failed to reach ci-stats service, retrying in ${seconds} seconds, [reason=${reason}], [error=${error.message}]`); + await new Promise(resolve => setTimeout(resolve, seconds * 1000)); } } } diff --git a/packages/kbn-rule-data-utils/src/technical_field_names.ts b/packages/kbn-rule-data-utils/src/technical_field_names.ts index fa3d61d00529c..86a036bbb9fe2 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -28,7 +28,7 @@ const ALERT_DURATION = `${ALERT_NAMESPACE}.duration.us` as const; const ALERT_END = `${ALERT_NAMESPACE}.end` as const; const ALERT_EVALUATION_THRESHOLD = `${ALERT_NAMESPACE}.evaluation.threshold` as const; const ALERT_EVALUATION_VALUE = `${ALERT_NAMESPACE}.evaluation.value` as const; -const ALERT_ID = `${ALERT_NAMESPACE}.id` as const; +const ALERT_INSTANCE_ID = `${ALERT_NAMESPACE}.instance.id` as const; const ALERT_REASON = `${ALERT_NAMESPACE}.reason` as const; const ALERT_RISK_SCORE = `${ALERT_NAMESPACE}.risk_score` as const; const ALERT_SEVERITY = `${ALERT_NAMESPACE}.severity` as const; @@ -94,7 +94,7 @@ const fields = { ALERT_END, ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_VALUE, - ALERT_ID, + ALERT_INSTANCE_ID, ALERT_RULE_CONSUMER, ALERT_RULE_PRODUCER, ALERT_REASON, @@ -143,7 +143,7 @@ export { ALERT_END, ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_VALUE, - ALERT_ID, + ALERT_INSTANCE_ID, ALERT_NAMESPACE, ALERT_RULE_NAMESPACE, ALERT_RULE_CONSUMER, diff --git a/renovate.json5 b/renovate.json5 index faf9859f21204..b1464ad5040f0 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -1,6 +1,7 @@ { extends: [ 'config:base', + ':disableDependencyDashboard', ], ignorePaths: [ '**/__fixtures__/**', @@ -12,12 +13,11 @@ baseBranches: [ 'master', '7.x', - '7.13', + '7.15', ], prConcurrentLimit: 0, prHourlyLimit: 0, separateMajorMinor: false, - masterIssue: true, rangeStrategy: 'bump', semanticCommits: false, vulnerabilityAlerts: { @@ -39,7 +39,7 @@ packageNames: ['@elastic/charts'], reviewers: ['markov00', 'nickofthyme'], matchBaseBranches: ['master'], - labels: ['release_note:skip', 'v8.0.0', 'v7.14.0', 'auto-backport'], + labels: ['release_note:skip', 'v8.0.0', 'v7.16.0', 'auto-backport'], enabled: true, }, { @@ -69,9 +69,9 @@ { groupName: 'vega related modules', packageNames: ['vega', 'vega-lite', 'vega-schema-url-parser', 'vega-tooltip'], - reviewers: ['team:kibana-app'], + reviewers: ['team:kibana-vis-editors'], matchBaseBranches: ['master'], - labels: ['Feature:Vega', 'Team:KibanaApp'], + labels: ['Feature:Vega', 'Team:VisEditors'], enabled: true, }, ], diff --git a/src/core/public/application/integration_tests/utils.tsx b/src/core/public/application/integration_tests/utils.tsx index dcf071719c11a..455d19956f7e8 100644 --- a/src/core/public/application/integration_tests/utils.tsx +++ b/src/core/public/application/integration_tests/utils.tsx @@ -21,13 +21,18 @@ export const createRenderer = (element: ReactElement | null): Renderer => { const dom: Dom = element && mount({element}); return () => - new Promise(async (resolve) => { - if (dom) { - await act(async () => { - dom.update(); - }); + new Promise(async (resolve, reject) => { + try { + if (dom) { + await act(async () => { + dom.update(); + }); + } + + setImmediate(() => resolve(dom)); // flushes any pending promises + } catch (error) { + reject(error); } - setImmediate(() => resolve(dom)); // flushes any pending promises }); }; diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index 86cb9198e0699..4c056e748f06e 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -27,8 +27,12 @@ describe('AppContainer', () => { }); const flushPromises = async () => { - await new Promise(async (resolve) => { - setImmediate(() => resolve()); + await new Promise(async (resolve, reject) => { + try { + setImmediate(() => resolve()); + } catch (error) { + reject(error); + } }); }; diff --git a/src/core/public/chrome/ui/header/header_action_menu.test.tsx b/src/core/public/chrome/ui/header/header_action_menu.test.tsx index 386e48e745e80..201be8848bac8 100644 --- a/src/core/public/chrome/ui/header/header_action_menu.test.tsx +++ b/src/core/public/chrome/ui/header/header_action_menu.test.tsx @@ -26,13 +26,18 @@ describe('HeaderActionMenu', () => { }); const refresh = () => { - new Promise(async (resolve) => { - if (component) { - act(() => { - component.update(); - }); + new Promise(async (resolve, reject) => { + try { + if (component) { + act(() => { + component.update(); + }); + } + + setImmediate(() => resolve(component)); // flushes any pending promises + } catch (error) { + reject(error); } - setImmediate(() => resolve(component)); // flushes any pending promises }); }; diff --git a/src/core/public/deprecations/deprecations_client.test.ts b/src/core/public/deprecations/deprecations_client.test.ts index a998a03772cca..cca81f4687a97 100644 --- a/src/core/public/deprecations/deprecations_client.test.ts +++ b/src/core/public/deprecations/deprecations_client.test.ts @@ -82,6 +82,7 @@ describe('DeprecationsClient', () => { it('returns true if deprecation has correctiveActions.api', async () => { const deprecationsClient = new DeprecationsClient({ http }); const mockDeprecationDetails: DomainDeprecationDetails = { + title: 'some-title', domainId: 'testPluginId-1', message: 'some-message', level: 'warning', @@ -102,6 +103,7 @@ describe('DeprecationsClient', () => { it('returns false if deprecation is missing correctiveActions.api', async () => { const deprecationsClient = new DeprecationsClient({ http }); const mockDeprecationDetails: DomainDeprecationDetails = { + title: 'some-title', domainId: 'testPluginId-1', message: 'some-message', level: 'warning', @@ -120,6 +122,7 @@ describe('DeprecationsClient', () => { it('fails if deprecation is not resolvable', async () => { const deprecationsClient = new DeprecationsClient({ http }); const mockDeprecationDetails: DomainDeprecationDetails = { + title: 'some-title', domainId: 'testPluginId-1', message: 'some-message', level: 'warning', @@ -129,15 +132,18 @@ describe('DeprecationsClient', () => { }; const result = await deprecationsClient.resolveDeprecation(mockDeprecationDetails); - expect(result).toEqual({ - status: 'fail', - reason: 'deprecation has no correctiveAction via api.', - }); + expect(result).toMatchInlineSnapshot(` + Object { + "reason": "This deprecation cannot be resolved automatically.", + "status": "fail", + } + `); }); it('fetches the deprecation api', async () => { const deprecationsClient = new DeprecationsClient({ http }); const mockDeprecationDetails: DomainDeprecationDetails = { + title: 'some-title', domainId: 'testPluginId-1', message: 'some-message', level: 'warning', @@ -171,6 +177,7 @@ describe('DeprecationsClient', () => { const deprecationsClient = new DeprecationsClient({ http }); const mockResponse = 'Failed to fetch'; const mockDeprecationDetails: DomainDeprecationDetails = { + title: 'some-title', domainId: 'testPluginId-1', message: 'some-message', level: 'warning', diff --git a/src/core/public/deprecations/deprecations_client.ts b/src/core/public/deprecations/deprecations_client.ts index e510ab1e79d17..4b9cfca1986ba 100644 --- a/src/core/public/deprecations/deprecations_client.ts +++ b/src/core/public/deprecations/deprecations_client.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import type { HttpStart } from '../http'; import type { DomainDeprecationDetails, DeprecationsGetResponse } from '../../server/types'; @@ -52,7 +53,9 @@ export class DeprecationsClient { if (typeof correctiveActions.api !== 'object') { return { status: 'fail', - reason: 'deprecation has no correctiveAction via api.', + reason: i18n.translate('core.deprecations.noCorrectiveAction', { + defaultMessage: 'This deprecation cannot be resolved automatically.', + }), }; } diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 4b1aaf9eb19c1..f3ef7c550e57d 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -30,6 +30,10 @@ export class DocLinksService { ELASTIC_WEBSITE_URL, links: { settings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/settings.html`, + apm: { + kibanaSettings: `${KIBANA_DOCS}apm-settings-in-kibana.html`, + supportedServiceMaps: `${KIBANA_DOCS}service-maps.html#service-maps-supported`, + }, canvas: { guide: `${KIBANA_DOCS}canvas.html`, }, @@ -204,6 +208,7 @@ export class DocLinksService { siem: { guide: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/index.html`, gettingStarted: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/index.html`, + privileges: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/sec-requirements.html`, ml: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/machine-learning.html`, ruleChangeLog: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/prebuilt-rules-changelog.html`, detectionsReq: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/detections-permissions-section.html`, @@ -450,6 +455,10 @@ export interface DocLinksStart { readonly ELASTIC_WEBSITE_URL: string; readonly links: { readonly settings: string; + readonly apm: { + readonly kibanaSettings: string; + readonly supportedServiceMaps: string; + }; readonly canvas: { readonly guide: string; }; @@ -569,6 +578,7 @@ export interface DocLinksStart { readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { + readonly privileges: string; readonly guide: string; readonly gettingStarted: string; readonly ml: string; diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap index 4ef5eb8f56d2f..54e223cdc5d41 100644 --- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap +++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap @@ -57,7 +57,7 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiColumnSelector.searchcolumns": "Search columns", "euiColumnSelector.selectAll": "Show all", "euiColumnSorting.button": "Sort fields", - "euiColumnSorting.buttonActive": "fields sorted", + "euiColumnSorting.buttonActive": [Function], "euiColumnSorting.clearAll": "Clear sorting", "euiColumnSorting.emptySorting": "Currently no fields are sorted", "euiColumnSorting.pickFields": "Pick fields to sort by", @@ -104,9 +104,11 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiFieldPassword.maskPassword": "Mask password", "euiFieldPassword.showPassword": "Show password as plain text. Note: this will visually expose your password on the screen.", "euiFilePicker.clearSelectedFiles": "Clear selected files", - "euiFilePicker.filesSelected": "files selected", + "euiFilePicker.filesSelected": [Function], + "euiFilePicker.promptText": "Select or drag and drop a file", "euiFilePicker.removeSelected": "Remove", - "euiFilterButton.filterBadge": [Function], + "euiFilterButton.filterBadgeActiveAriaLabel": [Function], + "euiFilterButton.filterBadgeAvailableAriaLabel": [Function], "euiFlyout.closeAriaLabel": "Close this dialog", "euiForm.addressFormErrors": "Please address the highlighted errors.", "euiFormControlLayoutClearButton.label": "Clear input", diff --git a/src/core/public/i18n/i18n_eui_mapping.test.ts b/src/core/public/i18n/i18n_eui_mapping.test.ts index 1b80257266d4c..d8d48a8e5f1d5 100644 --- a/src/core/public/i18n/i18n_eui_mapping.test.ts +++ b/src/core/public/i18n/i18n_eui_mapping.test.ts @@ -74,6 +74,11 @@ describe('@elastic/eui i18n tokens', () => { }); test('defaultMessage is in sync with defString', () => { + // Certain complex tokens (e.g. ones that have a function as a defaultMessage) + // need custom i18n handling, and can't be checked for basic defString equality + const tokensToSkip = ['euiColumnSorting.buttonActive']; + if (tokensToSkip.includes(token)) return; + // Clean up typical errors from the `@elastic/eui` extraction token tool const normalizedDefString = defString // Quoted words should use double-quotes diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index 133a2155f7430..4175dac712e82 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -272,9 +272,11 @@ export const getEuiContextMapping = (): EuiTokensObject => { 'euiColumnSorting.button': i18n.translate('core.euiColumnSorting.button', { defaultMessage: 'Sort fields', }), - 'euiColumnSorting.buttonActive': i18n.translate('core.euiColumnSorting.buttonActive', { - defaultMessage: 'fields sorted', - }), + 'euiColumnSorting.buttonActive': ({ numberOfSortedFields }: EuiValues) => + i18n.translate('core.euiColumnSorting.buttonActive', { + defaultMessage: '{numberOfSortedFields, plural, one {# field} other {# fields}} sorted', + values: { numberOfSortedFields }, + }), 'euiColumnSortingDraggable.activeSortLabel': ({ display }: EuiValues) => i18n.translate('core.euiColumnSortingDraggable.activeSortLabel', { defaultMessage: '{display} is sorting this data grid', @@ -514,16 +516,26 @@ export const getEuiContextMapping = (): EuiTokensObject => { 'euiFilePicker.clearSelectedFiles': i18n.translate('core.euiFilePicker.clearSelectedFiles', { defaultMessage: 'Clear selected files', }), - 'euiFilePicker.filesSelected': i18n.translate('core.euiFilePicker.filesSelected', { - defaultMessage: 'files selected', + 'euiFilePicker.filesSelected': ({ fileCount }: EuiValues) => + i18n.translate('core.euiFilePicker.filesSelected', { + defaultMessage: '{fileCount} files selected', + values: { fileCount }, + }), + 'euiFilePicker.promptText': i18n.translate('core.euiFilePicker.promptText', { + defaultMessage: 'Select or drag and drop a file', }), 'euiFilePicker.removeSelected': i18n.translate('core.euiFilePicker.removeSelected', { defaultMessage: 'Remove', }), - 'euiFilterButton.filterBadge': ({ count, hasActiveFilters }: EuiValues) => - i18n.translate('core.euiFilterButton.filterBadge', { - defaultMessage: '{count} {hasActiveFilters} filters', - values: { count, hasActiveFilters: hasActiveFilters ? 'active' : 'available' }, + 'euiFilterButton.filterBadgeActiveAriaLabel': ({ count }: EuiValues) => + i18n.translate('core.euiFilterButton.filterBadgeActiveAriaLabel', { + defaultMessage: '{count} active filters', + values: { count }, + }), + 'euiFilterButton.filterBadgeAvailableAriaLabel': ({ count }: EuiValues) => + i18n.translate('core.euiFilterButton.filterBadgeAvailableAriaLabel', { + defaultMessage: '{count} available filters', + values: { count }, }), 'euiFlyout.closeAriaLabel': i18n.translate('core.euiFlyout.closeAriaLabel', { defaultMessage: 'Close this dialog', diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 043759378faa3..f18e1dc26bd87 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -473,6 +473,10 @@ export interface DocLinksStart { // (undocumented) readonly links: { readonly settings: string; + readonly apm: { + readonly kibanaSettings: string; + readonly supportedServiceMaps: string; + }; readonly canvas: { readonly guide: string; }; @@ -592,6 +596,7 @@ export interface DocLinksStart { readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { + readonly privileges: string; readonly guide: string; readonly gettingStarted: string; readonly ml: string; diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts index 06c7116c8bebb..759e2375ce987 100644 --- a/src/core/server/config/deprecation/core_deprecations.test.ts +++ b/src/core/server/config/deprecation/core_deprecations.test.ts @@ -62,7 +62,7 @@ describe('core deprecations', () => { expect(migrated.server.xsrf.allowlist).toEqual(['/path']); expect(messages).toMatchInlineSnapshot(` Array [ - "\\"server.xsrf.whitelist\\" is deprecated and has been replaced by \\"server.xsrf.allowlist\\"", + "Setting \\"server.xsrf.whitelist\\" has been replaced by \\"server.xsrf.allowlist\\"", ] `); }); diff --git a/src/core/server/config/integration_tests/config_deprecation.test.ts b/src/core/server/config/integration_tests/config_deprecation.test.ts index 2d86281ce40d6..c941053a2f0a1 100644 --- a/src/core/server/config/integration_tests/config_deprecation.test.ts +++ b/src/core/server/config/integration_tests/config_deprecation.test.ts @@ -51,8 +51,8 @@ describe('configuration deprecations', () => { const logs = loggingSystemMock.collect(mockLoggingSystem); expect(logs.warn.flat()).toMatchInlineSnapshot(` Array [ - "optimize.lazy is deprecated and is no longer used", - "optimize.lazyPort is deprecated and is no longer used", + "You no longer need to configure \\"optimize.lazy\\".", + "You no longer need to configure \\"optimize.lazyPort\\".", "\\"logging.silent\\" has been deprecated and will be removed in 8.0. Moving forward, you can use \\"logging.root.level:off\\" in your logging configuration. ", ] `); diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts index a03f79096004b..941ac5afacb40 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.mock.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts @@ -47,6 +47,7 @@ const createStartContractMock = () => { keystoreConfigured: false, truststoreConfigured: false, }, + principal: 'unknown', }, http: { basePathConfigured: false, diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts index 7ecfa37492242..478cfe5daff46 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.test.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { ConfigPath } from '@kbn/config'; import { BehaviorSubject, Observable } from 'rxjs'; import { HotObservable } from 'rxjs/internal/testing/HotObservable'; import { TestScheduler } from 'rxjs/testing'; @@ -29,12 +30,31 @@ import { CORE_USAGE_STATS_TYPE } from './constants'; import { CoreUsageStatsClient } from './core_usage_stats_client'; describe('CoreUsageDataService', () => { + function getConfigServiceAtPathMockImplementation() { + return (path: ConfigPath) => { + if (path === 'elasticsearch') { + return new BehaviorSubject(RawElasticsearchConfig.schema.validate({})); + } else if (path === 'server') { + return new BehaviorSubject(RawHttpConfig.schema.validate({})); + } else if (path === 'logging') { + return new BehaviorSubject(RawLoggingConfig.schema.validate({})); + } else if (path === 'savedObjects') { + return new BehaviorSubject(RawSavedObjectsConfig.schema.validate({})); + } else if (path === 'kibana') { + return new BehaviorSubject(RawKibanaConfig.schema.validate({})); + } + return new BehaviorSubject({}); + }; + } + const getTestScheduler = () => new TestScheduler((actual, expected) => { expect(actual).toEqual(expected); }); let service: CoreUsageDataService; + let configService: ReturnType; + const mockConfig = { unused_config: {}, elasticsearch: { username: 'kibana_system', password: 'changeme' }, @@ -60,27 +80,11 @@ describe('CoreUsageDataService', () => { }, }; - const configService = configServiceMock.create({ - getConfig$: mockConfig, - }); - - configService.atPath.mockImplementation((path) => { - if (path === 'elasticsearch') { - return new BehaviorSubject(RawElasticsearchConfig.schema.validate({})); - } else if (path === 'server') { - return new BehaviorSubject(RawHttpConfig.schema.validate({})); - } else if (path === 'logging') { - return new BehaviorSubject(RawLoggingConfig.schema.validate({})); - } else if (path === 'savedObjects') { - return new BehaviorSubject(RawSavedObjectsConfig.schema.validate({})); - } else if (path === 'kibana') { - return new BehaviorSubject(RawKibanaConfig.schema.validate({})); - } - return new BehaviorSubject({}); - }); - const coreContext = mockCoreContext.create({ configService }); - beforeEach(() => { + configService = configServiceMock.create({ getConfig$: mockConfig }); + configService.atPath.mockImplementation(getConfigServiceAtPathMockImplementation()); + + const coreContext = mockCoreContext.create({ configService }); service = new CoreUsageDataService(coreContext); }); @@ -150,7 +154,7 @@ describe('CoreUsageDataService', () => { describe('start', () => { describe('getCoreUsageData', () => { - it('returns core metrics for default config', async () => { + function setup() { const http = httpServiceMock.createInternalSetupContract(); const metrics = metricsServiceMock.createInternalSetupContract(); const savedObjectsStartPromise = Promise.resolve( @@ -208,6 +212,11 @@ describe('CoreUsageDataService', () => { exposedConfigsToUsage: new Map(), elasticsearch, }); + return { getCoreUsageData }; + } + + it('returns core metrics for default config', async () => { + const { getCoreUsageData } = setup(); expect(getCoreUsageData()).resolves.toMatchInlineSnapshot(` Object { "config": Object { @@ -226,6 +235,7 @@ describe('CoreUsageDataService', () => { "logQueries": false, "numberOfHostsConfigured": 1, "pingTimeoutMs": 30000, + "principal": "unknown", "requestHeadersWhitelistConfigured": false, "requestTimeoutMs": 30000, "shardTimeoutMs": 30000, @@ -354,6 +364,60 @@ describe('CoreUsageDataService', () => { } `); }); + + describe('elasticsearch.principal', () => { + async function doTest({ + username, + serviceAccountToken, + expectedPrincipal, + }: { + username?: string; + serviceAccountToken?: string; + expectedPrincipal: string; + }) { + const defaultMockImplementation = getConfigServiceAtPathMockImplementation(); + configService.atPath.mockImplementation((path) => { + if (path === 'elasticsearch') { + return new BehaviorSubject( + RawElasticsearchConfig.schema.validate({ username, serviceAccountToken }) + ); + } + return defaultMockImplementation(path); + }); + const { getCoreUsageData } = setup(); + return expect(getCoreUsageData()).resolves.toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + elasticsearch: expect.objectContaining({ principal: expectedPrincipal }), + }), + }) + ); + } + + it('returns expected usage data for elastic.username "elastic"', async () => { + return doTest({ username: 'elastic', expectedPrincipal: 'elastic_user' }); + }); + + it('returns expected usage data for elastic.username "kibana"', async () => { + return doTest({ username: 'kibana', expectedPrincipal: 'kibana_user' }); + }); + + it('returns expected usage data for elastic.username "kibana_system"', async () => { + return doTest({ username: 'kibana_system', expectedPrincipal: 'kibana_system_user' }); + }); + + it('returns expected usage data for elastic.username anything else', async () => { + return doTest({ username: 'anything else', expectedPrincipal: 'other_user' }); + }); + + it('returns expected usage data for elastic.serviceAccountToken', async () => { + // Note: elastic.username and elastic.serviceAccountToken are mutually exclusive + return doTest({ + serviceAccountToken: 'any', + expectedPrincipal: 'kibana_service_account', + }); + }); + }); }); describe('getConfigsUsageData', () => { diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index 7cf38dddc563e..73f63d4d634df 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -29,6 +29,7 @@ import type { CoreUsageDataStart, CoreUsageDataSetup, ConfigUsageData, + CoreConfigUsageData, } from './types'; import { isConfigured } from './is_configured'; import { ElasticsearchServiceStart } from '../elasticsearch'; @@ -253,6 +254,7 @@ export class CoreUsageDataService implements CoreService = { exposeToBrowser: { defaultAppId: true, @@ -97,12 +108,23 @@ export const config: PluginConfigDescriptor = { return completeConfig; } addDeprecation({ - message: `kibana.defaultAppId is deprecated and will be removed in 8.0. Please use the "defaultRoute" advanced setting instead`, + title: i18n.translate('kibana_legacy.deprecations.defaultAppIdTitle', { + defaultMessage: 'Setting "kibana.defaultAppId" is deprecated', + }), + message: i18n.translate('kibana_legacy.deprecations.defaultAppIdMessage', { + defaultMessage: 'Use the "defaultRoute" advanced setting instead of "kibana.defaultAppId".', + }), correctiveActions: { manualSteps: [ - 'Go to Stack Management > Advanced Settings', - 'Update the "defaultRoute" setting under the General section', - 'Remove "kibana.defaultAppId" from the kibana.yml config file', + i18n.translate('kibana_legacy.deprecations.defaultAppId.manualStepOneMessage', { + defaultMessage: 'Go to Stack Management > Advanced Settings.', + }), + i18n.translate('kibana_legacy.deprecations.defaultAppId.manualStepTwoMessage', { + defaultMessage: 'Update the "defaultRoute" setting in the General section.', + }), + i18n.translate('kibana_legacy.deprecations.defaultAppId.manualStepThreeMessage', { + defaultMessage: 'Remove "kibana.defaultAppId" from the kibana.yml config file.', + }), ], }, }); @@ -138,39 +160,49 @@ To check the full TS types of the service please check the [generated core docs] ### Example ```ts import { DeprecationsDetails, GetDeprecationsContext } from 'src/core/server'; +import { i18n } from '@kbn/i18n'; async function getDeprecations({ esClient, savedObjectsClient }: GetDeprecationsContext): Promise { const deprecations: DeprecationsDetails[] = []; const testDashboardUser = await getTestDashboardUser(savedObjectsClient); if (testDashboardUser) { - deprecations.push({ - message: 'User "test_dashboard_user" is using a deprecated role: "kibana_user"', - documentationUrl: 'https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html', - level: 'critical', - correctiveActions: { - api: { - path: '/internal/security/users/test_dashboard_user', - method: 'POST', - body: { - username: 'test_dashboard_user', - roles: [ - 'machine_learning_user', - 'enrich_user', - 'kibana_admin' - ], - full_name: 'Alison Goryachev', - email: 'alisongoryachev@gmail.com', - metadata: {}, - enabled: true - } + deprecations.push({ + title: i18n.translate('security.deprecations.kibanaUserRoleTitle', { + defaultMessage: 'Deprecated roles are assigned to some users', + }), + message: i18n.translate('security.deprecations.kibanaUserRoleMessage', { + defaultMessage: 'User "test_dashboard_user" is using a deprecated role: "kibana_user".', + }), + documentationUrl: 'https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html', + level: 'critical', + correctiveActions: { + api: { + path: '/internal/security/users/test_dashboard_user', + method: 'POST', + body: { + username: 'test_dashboard_user', + roles: [ + 'machine_learning_user', + 'enrich_user', + 'kibana_admin' + ], + full_name: 'Alison Goryachev', + email: 'alisongoryachev@gmail.com', + metadata: {}, + enabled: true + } + }, + manualSteps: [ + i18n.translate('security.deprecations.kibanaUserRole.manualStepOneMessage', { + defaultMessage: 'Switch all users with the "kibana_user" role to the kibana_admin role in Management > Security > Users.', + }), + i18n.translate('security.deprecations.kibanaUserRole.manualStepTwoMessage', { + defaultMessage: 'Update all mappings in Management > Security > Role Mappings to assign the "kibana_admin" role instead of the "kibana_user" role.' + }), + ], }, - manualSteps: [ - 'Using Kibana user management, change all users using the kibana_user role to the kibana_admin role.', - 'Using Kibana role-mapping management, change all role-mappings which assing the kibana_user role to the kibana_admin role.' - ] - }, - }); + }); } return deprecations; @@ -204,7 +236,128 @@ Currently we do not have test objects to run functional tests against the Upgrad Yes. Using this service should help users find and resolve any issues specific to their deployment before upgrading. We recommend adding a `documentationUrl` for every deprecation you expose to further assist our users if they need extra help. +## Writing deprecation details + +State what is being deprecated and what action the user needs to take: + +> Abc is deprecated. Use Xyz to do the thing. + +Provide as much context as possible for what triggered the deprecation warning. +If action is not required (for example the default behavior is changing), describe the impact of doing nothing. + +Examples: +- > Setting `xpack.reporting.roles.enabled` is deprecated. Use feature controls to grant reporting privileges. +- > The Joda Time century-of era-formatter (C) is deprecated. Use a `java.time` formatter instead. +- > The default for the `cluster.routing.allocation.disk.watermark` setting is changing from false to true. + > If you do not explicitly configure this setting when you upgrade, indices in this one node cluster will + > become read-only if disk usage reaches 95%. + ## Note on i18n -We have decided to support i18n to the exposed deprecations for a better user experience when using the UA. -We will inject `i18n` into the deprecation function to enable teams to use it before fully documenting its usage. -For context follow [this issue](https://github.com/elastic/kibana/issues/99072). +All deprecation titles, messsages, and manual steps should be wrapped in `i18n.translate`. This +provides a better user experience using different locales. Follow the writing guidelines below for +best practices to writing the i18n messages and ids. + +### Writing guidelines +The deprecation service enables you to specify a `title`, `message`, `documentationUrl`, +and the `manual steps` for resolving a deprecation issue. + +#### Title: +No end punctuation is required. +i18n id: `{plugin_domain}.deprecations.{deprecationTitle}Title` + +Example: +```ts +title: i18n.translate('xpack.reporting.deprecations.reportingRoleTitle', { + defaultMessage: `Found deprecated reporting roles`, +}) +``` + +#### Message +Keep it brief, but multiple sentences are allowed if needed. +i18n id: `{plugin_domain}.deprecations.{deprecationTitle}Message` + +Example: +```ts +message: i18n.translate('xpack.reporting.deprecations.reportingRoleMessage', { + defaultMessage: `The deprecated "${deprecatedRole}" role has been found for ${numReportingUsers} user(s): "${usernames}"`, + values: { deprecatedRole, numReportingUsers, usernames }, +}), +``` + +#### Documentation URL +Don’t link to the Migration guide/breaking changes. +Only specify a doc URL if the user truly needs to “learn more” to understand what actions they need to take. + +Example: +```ts +documentationUrl: 'https://www.elastic.co/guide/en/kibana/current/secure-reporting.html', +``` +#### Manual steps +State the action first for each step. +i18n id: `{plugin_domain}.deprecations.{deprecationTitle}.manualStep{Step#}Message` + +Example: +```ts +manualSteps: [ + i18n.translate('xpack.reporting.deprecations.reportingRole.manualStepTwoMessage', { + defaultMessage: `Create one or more custom roles that provide Kibana application privileges to reporting features in **Management > Security > Roles**.`, + }), + i18n.translate('xpack.reporting.deprecations.reportingRole.manualStepThreeMessage', { + defaultMessage: `Assign the custom role(s) as desired, and remove the "${deprecatedRole}" role from the user(s).`, + values: { deprecatedRole }, + }), +] +``` + +#### General Guidelines + +##### What is deprecated +Use the present tense: +- Types are deprecated in geo_shape queries. +- Sorting is deprecated in reindex requests. + +Avoid: +- The type should no longer be specified in geo_shape queries. +- Sorting has been deprecated in reindex requests. + +##### What action the user needs to take +Use the imperative voice: +- Do not specify a type in the indexed_shape section. +- Use query filtering to reindex a subset of documents. + +Avoid: +- Please use query filtering instead. +- You should use query filtering instead. +- Instead consider using query filtering to find the desired subset of data. + +##### Context +Where possible, provide the specific context that resulted in the warning: +- The Abc timezone used by rollup job Def is deprecated. Use Xyz instead. + +##### Impact +Many deprecations are clearcut--you are using this old thing and need to switch to using this new thing. +Others are more nuanced and don’t necessarily require any changes. In this case, the warning needs to address +the impact of not taking action: +- The default for the `cluster.routing.allocation.disk.watermark` setting is changing from false to true. + If you do not explicitly configure this setting when you upgrade, indices in this one node cluster will + become read-only if disk usage reaches 95%. + +##### Version +You do not need to include any form of "and will be removed in a future release". +The assumption is that deprecated things are going to be removed, and the standard schedule for removal +is the next major version. + +If things are targeted for removal in a specific minor release, the message should include that information: +- Abc is deprecated. Use Xyz to do the thing. Support for Abc will be removed in n.n. + +If an item is deprecated, but won’t be removed in the next major version, the message should indicate that: +- Abc is deprecated. Use Xyz to do the thing. Support for Abc will be removed following the release of n.0. + +Avoid: +- Xyz is deprecated and will be removed in 8.0. +- Xyz is deprecated and will be unsupported in future. +- Xyz is deprecated and will not be supported in the next major version of Elasticsearch. + +##### Formatting +- Sentence style capitalization and punctuation. +- Avoid quotes for emphasis. diff --git a/src/core/server/deprecations/deprecations_factory.test.ts b/src/core/server/deprecations/deprecations_factory.test.ts index 187f3880f9998..73beb84f57fa6 100644 --- a/src/core/server/deprecations/deprecations_factory.test.ts +++ b/src/core/server/deprecations/deprecations_factory.test.ts @@ -124,16 +124,21 @@ describe('DeprecationsFactory', () => { `Failed to get deprecations info for plugin "${domainId}".`, mockError ); - expect(derpecations).toStrictEqual([ - { - domainId, - message: `Failed to get deprecations info for plugin "${domainId}".`, - level: 'fetch_error', - correctiveActions: { - manualSteps: ['Check Kibana server logs for error message.'], + expect(derpecations).toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Check Kibana server logs for error message.", + ], + }, + "domainId": "mockPlugin", + "level": "fetch_error", + "message": "Unable to fetch deprecations info for plugin mockPlugin.", + "title": "Failed to fetch deprecations for mockPlugin", }, - }, - ]); + ] + `); }); it(`returns successful results even when some getDeprecations fail`, async () => { @@ -167,7 +172,8 @@ describe('DeprecationsFactory', () => { ...mockPluginDeprecationsInfo.map((info) => ({ ...info, domainId: 'mockPlugin' })), { domainId: 'anotherMockPlugin', - message: `Failed to get deprecations info for plugin "anotherMockPlugin".`, + title: 'Failed to fetch deprecations for anotherMockPlugin', + message: 'Unable to fetch deprecations info for plugin anotherMockPlugin.', level: 'fetch_error', correctiveActions: { manualSteps: ['Check Kibana server logs for error message.'], diff --git a/src/core/server/deprecations/deprecations_factory.ts b/src/core/server/deprecations/deprecations_factory.ts index 3699c088e20f1..9905f0b26b4f3 100644 --- a/src/core/server/deprecations/deprecations_factory.ts +++ b/src/core/server/deprecations/deprecations_factory.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import { DeprecationsRegistry } from './deprecations_registry'; import type { Logger } from '../logging'; import type { @@ -89,10 +90,24 @@ export class DeprecationsFactory { ); return [ { - message: `Failed to get deprecations info for plugin "${domainId}".`, + title: i18n.translate('core.deprecations.deprecations.fetchFailedTitle', { + defaultMessage: `Failed to fetch deprecations for {domainId}`, + values: { domainId }, + }), + message: i18n.translate('core.deprecations.deprecations.fetchFailedMessage', { + defaultMessage: 'Unable to fetch deprecations info for plugin {domainId}.', + values: { domainId }, + }), level: 'fetch_error', correctiveActions: { - manualSteps: ['Check Kibana server logs for error message.'], + manualSteps: [ + i18n.translate( + 'core.deprecations.deprecations.fetchFailed.manualStepOneMessage', + { + defaultMessage: 'Check Kibana server logs for error message.', + } + ), + ], }, }, ]; diff --git a/src/core/server/deprecations/deprecations_service.test.ts b/src/core/server/deprecations/deprecations_service.test.ts index 75a0d6a63d919..0e8aaf3de49c9 100644 --- a/src/core/server/deprecations/deprecations_service.test.ts +++ b/src/core/server/deprecations/deprecations_service.test.ts @@ -110,6 +110,7 @@ describe('DeprecationsService', () => { "level": "critical", "message": "testMessage", "requireRestart": true, + "title": "testDomain has a deprecated setting", }, ] `); diff --git a/src/core/server/deprecations/deprecations_service.ts b/src/core/server/deprecations/deprecations_service.ts index 7c4f74fe7d0ec..c41567d88a2aa 100644 --- a/src/core/server/deprecations/deprecations_service.ts +++ b/src/core/server/deprecations/deprecations_service.ts @@ -33,6 +33,7 @@ import { SavedObjectsClientContract } from '../saved_objects/types'; * @example * ```ts * import { DeprecationsDetails, GetDeprecationsContext, CoreSetup } from 'src/core/server'; + * import { i18n } from '@kbn/i18n'; * * async function getDeprecations({ esClient, savedObjectsClient }: GetDeprecationsContext): Promise { * const deprecations: DeprecationsDetails[] = []; @@ -41,52 +42,44 @@ import { SavedObjectsClientContract } from '../saved_objects/types'; * if (count > 0) { * // Example of a manual correctiveAction * deprecations.push({ - * message: `You have ${count} Timelion worksheets. The Timelion app will be removed in 8.0. To continue using your Timelion worksheets, migrate them to a dashboard.`, + * title: i18n.translate('xpack.timelion.deprecations.worksheetsTitle', { + * defaultMessage: 'Timelion worksheets are deprecated' + * }), + * message: i18n.translate('xpack.timelion.deprecations.worksheetsMessage', { + * defaultMessage: 'You have {count} Timelion worksheets. Migrate your Timelion worksheets to a dashboard to continue using them.', + * values: { count }, + * }), * documentationUrl: * 'https://www.elastic.co/guide/en/kibana/current/create-panels-with-timelion.html', * level: 'warning', * correctiveActions: { * manualSteps: [ - * 'Navigate to the Kibana Dashboard and click "Create dashboard".', - * 'Select Timelion from the "New Visualization" window.', - * 'Open a new tab, open the Timelion app, select the chart you want to copy, then copy the chart expression.', - * 'Go to Timelion, paste the chart expression in the Timelion expression field, then click Update.', - * 'In the toolbar, click Save.', - * 'On the Save visualization window, enter the visualization Title, then click Save and return.', + * i18n.translate('xpack.timelion.deprecations.worksheets.manualStepOneMessage', { + * defaultMessage: 'Navigate to the Kibana Dashboard and click "Create dashboard".', + * }), + * i18n.translate('xpack.timelion.deprecations.worksheets.manualStepTwoMessage', { + * defaultMessage: 'Select Timelion from the "New Visualization" window.', + * }), * ], + * api: { + * path: '/internal/security/users/test_dashboard_user', + * method: 'POST', + * body: { + * username: 'test_dashboard_user', + * roles: [ + * "machine_learning_user", + * "enrich_user", + * "kibana_admin" + * ], + * full_name: "Alison Goryachev", + * email: "alisongoryachev@gmail.com", + * metadata: {}, + * enabled: true + * } + * }, * }, * }); * } - * - * // Example of an api correctiveAction - * deprecations.push({ - * "message": "User 'test_dashboard_user' is using a deprecated role: 'kibana_user'", - * "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html", - * "level": "critical", - * "correctiveActions": { - * "api": { - * "path": "/internal/security/users/test_dashboard_user", - * "method": "POST", - * "body": { - * "username": "test_dashboard_user", - * "roles": [ - * "machine_learning_user", - * "enrich_user", - * "kibana_admin" - * ], - * "full_name": "Alison Goryachev", - * "email": "alisongoryachev@gmail.com", - * "metadata": {}, - * "enabled": true - * } - * }, - * "manualSteps": [ - * "Using Kibana user management, change all users using the kibana_user role to the kibana_admin role.", - * "Using Kibana role-mapping management, change all role-mappings which assing the kibana_user role to the kibana_admin role." - * ] - * }, - * }); - * * return deprecations; * } * @@ -192,16 +185,19 @@ export class DeprecationsService const deprecationsRegistry = deprecationsFactory.getRegistry(domainId); deprecationsRegistry.registerDeprecations({ getDeprecations: () => { - return deprecationsContexts.map(({ message, correctiveActions, documentationUrl }) => { - return { - level: 'critical', - deprecationType: 'config', - message, - correctiveActions, - documentationUrl, - requireRestart: true, - }; - }); + return deprecationsContexts.map( + ({ title, message, correctiveActions, documentationUrl }) => { + return { + title: title || `${domainId} has a deprecated setting`, + level: 'critical', + deprecationType: 'config', + message, + correctiveActions, + documentationUrl, + requireRestart: true, + }; + } + ); }, }); } diff --git a/src/core/server/deprecations/types.ts b/src/core/server/deprecations/types.ts index 486fec5dfd8be..c924cacd02e28 100644 --- a/src/core/server/deprecations/types.ts +++ b/src/core/server/deprecations/types.ts @@ -16,7 +16,15 @@ export interface DomainDeprecationDetails extends DeprecationsDetails { } export interface DeprecationsDetails { - /* The message to be displayed for the deprecation. */ + /** + * The title of the deprecation. + * Check the README for writing deprecations in `src/core/server/deprecations/README.mdx` + */ + title: string; + /** + * The description message to be displayed for the deprecation. + * Check the README for writing deprecations in `src/core/server/deprecations/README.mdx` + */ message: string; /** * levels: @@ -60,6 +68,7 @@ export interface DeprecationsDetails { * Specify a list of manual steps users need to follow to * fix the deprecation before upgrade. Required even if an API * corrective action is set in case the API fails. + * Check the README for writing deprecations in `src/core/server/deprecations/README.mdx` */ manualSteps: string[]; }; diff --git a/src/core/server/elasticsearch/client/client_config.ts b/src/core/server/elasticsearch/client/client_config.ts index 27d6f877a5572..a6b0891fc12dd 100644 --- a/src/core/server/elasticsearch/client/client_config.ts +++ b/src/core/server/elasticsearch/client/client_config.ts @@ -56,6 +56,9 @@ export function parseClientOptions( ...DEFAULT_HEADERS, ...config.customHeaders, }, + // do not make assumption on user-supplied data content + // fixes https://github.com/elastic/kibana/issues/101944 + disablePrototypePoisoningProtection: true, }; if (config.pingTimeout != null) { diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts index f954b121320fe..4e2c9c22f42f8 100644 --- a/src/core/server/elasticsearch/client/configure_client.test.ts +++ b/src/core/server/elasticsearch/client/configure_client.test.ts @@ -10,6 +10,7 @@ import { Buffer } from 'buffer'; import { Readable } from 'stream'; import { RequestEvent, errors } from '@elastic/elasticsearch'; +import type { Client } from '@elastic/elasticsearch'; import type { TransportRequestOptions, TransportRequestParams, @@ -18,7 +19,6 @@ import type { import { parseClientOptionsMock, ClientMock } from './configure_client.test.mocks'; import { loggingSystemMock } from '../../logging/logging_system.mock'; -import { EventEmitter } from 'events'; import type { ElasticsearchClientConfig } from './client_config'; import { configureClient } from './configure_client'; @@ -32,7 +32,10 @@ const createFakeConfig = ( }; const createFakeClient = () => { - const client = new EventEmitter(); + const actualEs = jest.requireActual('@elastic/elasticsearch'); + const client = new actualEs.Client({ + nodes: ['http://localhost'], // Enforcing `nodes` because it's mandatory + }); jest.spyOn(client, 'on'); return client; }; @@ -67,6 +70,14 @@ const createApiResponse = ({ }; }; +function getProductCheckValue(client: Client) { + const tSymbol = Object.getOwnPropertySymbols(client.transport || client).filter( + (symbol) => symbol.description === 'product check' + )[0]; + // @ts-expect-error `tSymbol` is missing in the index signature of Transport + return (client.transport || client)[tSymbol]; +} + describe('configureClient', () => { let logger: ReturnType; let config: ElasticsearchClientConfig; @@ -117,6 +128,24 @@ describe('configureClient', () => { expect(client.on).toHaveBeenCalledWith('response', expect.any(Function)); }); + describe('Product check', () => { + it('should not skip the product check for the unscoped client', () => { + const client = configureClient(config, { logger, type: 'test', scoped: false }); + expect(getProductCheckValue(client)).toBe(0); + }); + + it('should skip the product check for the scoped client', () => { + const client = configureClient(config, { logger, type: 'test', scoped: true }); + expect(getProductCheckValue(client)).toBe(2); + }); + + it('should skip the product check for the children of the scoped client', () => { + const client = configureClient(config, { logger, type: 'test', scoped: true }); + const asScoped = client.child({ headers: { 'x-custom-header': 'Custom value' } }); + expect(getProductCheckValue(asScoped)).toBe(2); + }); + }); + describe('Client logging', () => { function createResponseWithBody(body?: RequestBody) { return createApiResponse({ diff --git a/src/core/server/elasticsearch/client/configure_client.ts b/src/core/server/elasticsearch/client/configure_client.ts index 35825ef765dbf..efd22365d44f3 100644 --- a/src/core/server/elasticsearch/client/configure_client.ts +++ b/src/core/server/elasticsearch/client/configure_client.ts @@ -49,6 +49,12 @@ export const configureClient = ( const client = new Client({ ...clientOptions, Transport: KibanaTransport }); addLogging(client, logger.get('query', type)); + // --------------------------------------------------------------------------------- // + // Hack to disable the "Product check" only in the scoped clients while we // + // come up with a better approach in https://github.com/elastic/kibana/issues/110675 // + if (scoped) skipProductCheck(client); + // --------------------------------------------------------------------------------- // + return client; }; @@ -131,3 +137,21 @@ const addLogging = (client: Client, logger: Logger) => { } }); }; + +/** + * Hack to skip the Product Check performed by the Elasticsearch-js client. + * We noticed that the scoped clients are always performing this check because + * of the way we initialize the clients. We'll discuss changing this in the issue + * https://github.com/elastic/kibana/issues/110675. In the meanwhile, let's skip + * it for the scoped clients. + * + * The hack is copied from the test/utils in the elasticsearch-js repo + * (https://github.com/elastic/elasticsearch-js/blob/master/test/utils/index.js#L45-L56) + */ +function skipProductCheck(client: Client) { + const tSymbol = Object.getOwnPropertySymbols(client.transport || client).filter( + (symbol) => symbol.description === 'product check' + )[0]; + // @ts-expect-error `tSymbol` is missing in the index signature of Transport + (client.transport || client)[tSymbol] = 2; +} diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.mock.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.mock.ts index 530203e659086..9471bbc1b87a6 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.mock.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.mock.ts @@ -11,6 +11,7 @@ import { buildActiveMappings } from '../core'; const { mergeTypes } = jest.requireActual('./kibana_migrator'); import { SavedObjectsType } from '../../types'; import { BehaviorSubject } from 'rxjs'; +import { ByteSizeValue } from '@kbn/config-schema'; const defaultSavedObjectTypes: SavedObjectsType[] = [ { @@ -37,6 +38,7 @@ const createMigrator = ( kibanaVersion: '8.0.0-testing', soMigrationsConfig: { batchSize: 100, + maxBatchSizeBytes: ByteSizeValue.parse('30kb'), scrollDuration: '15m', pollInterval: 1500, skip: false, diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index d0cc52f2dd9bd..6e10349f4b57c 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -15,6 +15,7 @@ import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; import { DocumentMigrator } from '../core/document_migrator'; +import { ByteSizeValue } from '@kbn/config-schema'; jest.mock('../core/document_migrator', () => { return { // Create a mock for spying on the constructor @@ -396,6 +397,7 @@ const mockOptions = ({ enableV2 }: { enableV2: boolean } = { enableV2: false }) } as KibanaMigratorOptions['kibanaConfig'], soMigrationsConfig: { batchSize: 20, + maxBatchSizeBytes: ByteSizeValue.parse('20mb'), pollInterval: 20000, scrollDuration: '10m', skip: false, diff --git a/src/core/server/saved_objects/migrationsv2/README.md b/src/core/server/saved_objects/migrationsv2/README.md index 5bdc548987842..5121e66052f40 100644 --- a/src/core/server/saved_objects/migrationsv2/README.md +++ b/src/core/server/saved_objects/migrationsv2/README.md @@ -316,7 +316,10 @@ completed this step: - temp index has a write block - temp index is not found ### New control state +1. If `currentBatch` is the last batch in `transformedDocBatches` → `REINDEX_SOURCE_TO_TEMP_READ` +2. If there are more batches left in `transformedDocBatches` + → `REINDEX_SOURCE_TO_TEMP_INDEX_BULK` ## REINDEX_SOURCE_TO_TEMP_CLOSE_PIT ### Next action diff --git a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts index 4217ca599297a..82f642b928058 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts @@ -23,6 +23,27 @@ import type { IndexNotFound, } from './index'; +/** + * Given a document and index, creates a valid body for the Bulk API. + */ +export const createBulkOperationBody = (doc: SavedObjectsRawDoc, index: string) => { + return [ + { + index: { + _index: index, + _id: doc._id, + // overwrite existing documents + op_type: 'index', + // use optimistic concurrency control to ensure that outdated + // documents are only overwritten once with the latest version + if_seq_no: doc._seq_no, + if_primary_term: doc._primary_term, + }, + }, + doc._source, + ]; +}; + /** @internal */ export interface BulkOverwriteTransformedDocumentsParams { client: ElasticsearchClient; @@ -47,6 +68,10 @@ export const bulkOverwriteTransformedDocuments = ({ | RequestEntityTooLargeException, 'bulk_index_succeeded' > => () => { + const body = transformedDocs.flatMap((doc) => { + return createBulkOperationBody(doc, index); + }); + return client .bulk({ // Because we only add aliases in the MARK_VERSION_INDEX_READY step we @@ -60,23 +85,7 @@ export const bulkOverwriteTransformedDocuments = ({ wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, refresh, filter_path: ['items.*.error'], - body: transformedDocs.flatMap((doc) => { - return [ - { - index: { - _index: index, - _id: doc._id, - // overwrite existing documents - op_type: 'index', - // use optimistic concurrency control to ensure that outdated - // documents are only overwritten once with the latest version - if_seq_no: doc._seq_no, - if_primary_term: doc._primary_term, - }, - }, - doc._source, - ]; - }), + body, }) .then((res) => { // Filter out version_conflict_engine_exception since these just mean diff --git a/src/core/server/saved_objects/migrationsv2/initial_state.test.ts b/src/core/server/saved_objects/migrationsv2/initial_state.test.ts index 4066efeb65de0..26ba129cbeab4 100644 --- a/src/core/server/saved_objects/migrationsv2/initial_state.test.ts +++ b/src/core/server/saved_objects/migrationsv2/initial_state.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { ByteSizeValue } from '@kbn/config-schema'; import * as Option from 'fp-ts/Option'; import { SavedObjectsMigrationConfigType } from '../saved_objects_config'; import { SavedObjectTypeRegistry } from '../saved_objects_type_registry'; @@ -21,6 +22,7 @@ describe('createInitialState', () => { const migrationsConfig = ({ retryAttempts: 15, batchSize: 1000, + maxBatchSizeBytes: ByteSizeValue.parse('100mb'), } as unknown) as SavedObjectsMigrationConfigType; it('creates the initial state for the model based on the passed in parameters', () => { expect( @@ -37,6 +39,7 @@ describe('createInitialState', () => { }) ).toEqual({ batchSize: 1000, + maxBatchSizeBytes: ByteSizeValue.parse('100mb').getValueInBytes(), controlState: 'INIT', currentAlias: '.kibana_task_manager', excludeFromUpgradeFilterHooks: {}, diff --git a/src/core/server/saved_objects/migrationsv2/initial_state.ts b/src/core/server/saved_objects/migrationsv2/initial_state.ts index dce37b384a4f7..a61967be9242c 100644 --- a/src/core/server/saved_objects/migrationsv2/initial_state.ts +++ b/src/core/server/saved_objects/migrationsv2/initial_state.ts @@ -82,6 +82,7 @@ export const createInitialState = ({ retryDelay: 0, retryAttempts: migrationsConfig.retryAttempts, batchSize: migrationsConfig.batchSize, + maxBatchSizeBytes: migrationsConfig.maxBatchSizeBytes.getValueInBytes(), logs: [], unusedTypesQuery: excludeUnusedTypesQuery, knownTypes, diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/7.7.2_xpack_100k.test.ts similarity index 94% rename from src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts rename to src/core/server/saved_objects/migrationsv2/integration_tests/7.7.2_xpack_100k.test.ts index ed21349a700fc..41d89e2a01541 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/7.7.2_xpack_100k.test.ts @@ -17,7 +17,7 @@ import { InternalCoreStart } from '../../../internal_types'; import { Root } from '../../../root'; const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version; -const logFilePath = path.join(__dirname, 'migration_test_kibana.log'); +const logFilePath = path.join(__dirname, '7.7.2_xpack_100k.log'); async function removeLogFile() { // ignore errors if it doesn't exist @@ -61,9 +61,12 @@ describe('migration from 7.7.2-xpack with 100k objects', () => { }, }, }, - root: { - appenders: ['default', 'file'], - }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + ], }, }, { diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_failed_action_tasks.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/7_13_0_failed_action_tasks.test.ts similarity index 99% rename from src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_failed_action_tasks.test.ts rename to src/core/server/saved_objects/migrationsv2/integration_tests/7_13_0_failed_action_tasks.test.ts index 0788a7ecdf0b1..d70e034703158 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_failed_action_tasks.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/7_13_0_failed_action_tasks.test.ts @@ -12,7 +12,7 @@ import * as kbnTestServer from '../../../../test_helpers/kbn_server'; import { Root } from '../../../root'; import { ElasticsearchClient } from '../../../elasticsearch'; -const logFilePath = Path.join(__dirname, '7_13_failed_action_tasks_test.log'); +const logFilePath = Path.join(__dirname, '7_13_failed_action_tasks.log'); async function removeLogFile() { // ignore errors if it doesn't exist diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_transform_failures.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/7_13_0_transform_failures.test.ts similarity index 99% rename from src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_transform_failures.test.ts rename to src/core/server/saved_objects/migrationsv2/integration_tests/7_13_0_transform_failures.test.ts index 3258732c6fdd2..fb40bda81cba5 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_transform_failures.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/7_13_0_transform_failures.test.ts @@ -12,7 +12,7 @@ import Util from 'util'; import * as kbnTestServer from '../../../../test_helpers/kbn_server'; import { Root } from '../../../root'; -const logFilePath = Path.join(__dirname, '7_13_corrupt_transform_failures_test.log'); +const logFilePath = Path.join(__dirname, '7_13_corrupt_transform_failures.log'); const asyncUnlink = Util.promisify(Fs.unlink); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_unknown_types.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/7_13_0_unknown_types.test.ts similarity index 86% rename from src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_unknown_types.test.ts rename to src/core/server/saved_objects/migrationsv2/integration_tests/7_13_0_unknown_types.test.ts index aded389bbb595..0be8b1187af71 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_unknown_types.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/7_13_0_unknown_types.test.ts @@ -16,10 +16,12 @@ import { ElasticsearchClient } from '../../../elasticsearch'; import { Env } from '@kbn/config'; import { REPO_ROOT } from '@kbn/utils'; import { getEnvOptions } from '../../../config/mocks'; +import { retryAsync } from '../test_helpers/retry_async'; +import { LogRecord } from '@kbn/logging'; const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version; const targetIndex = `.kibana_${kibanaVersion}_001`; -const logFilePath = Path.join(__dirname, '7_13_unknown_types_test.log'); +const logFilePath = Path.join(__dirname, '7_13_unknown_types.log'); async function removeLogFile() { // ignore errors if it doesn't exist @@ -68,23 +70,30 @@ describe('migration v2', () => { await root.setup(); await root.start(); - const logFileContent = await fs.readFile(logFilePath, 'utf-8'); - const records = logFileContent - .split('\n') - .filter(Boolean) - .map((str) => JSON5.parse(str)); + let unknownDocsWarningLog: LogRecord; - const unknownDocsWarningLog = records.find((rec) => - rec.message.startsWith(`[.kibana] CHECK_UNKNOWN_DOCUMENTS`) - ); + await retryAsync( + async () => { + const logFileContent = await fs.readFile(logFilePath, 'utf-8'); + const records = logFileContent + .split('\n') + .filter(Boolean) + .map((str) => JSON5.parse(str)); + + unknownDocsWarningLog = records.find((rec) => + rec.message.startsWith(`[.kibana] CHECK_UNKNOWN_DOCUMENTS`) + ); - expect( - unknownDocsWarningLog.message.startsWith( - '[.kibana] CHECK_UNKNOWN_DOCUMENTS Upgrades will fail for 8.0+ because documents were found for unknown saved ' + - 'object types. To ensure that upgrades will succeed in the future, either re-enable plugins or delete ' + - `these documents from the "${targetIndex}" index after the current upgrade completes.` - ) - ).toBeTruthy(); + expect( + unknownDocsWarningLog.message.startsWith( + '[.kibana] CHECK_UNKNOWN_DOCUMENTS Upgrades will fail for 8.0+ because documents were found for unknown saved ' + + 'object types. To ensure that upgrades will succeed in the future, either re-enable plugins or delete ' + + `these documents from the "${targetIndex}" index after the current upgrade completes.` + ) + ).toBeTruthy(); + }, + { retryAttempts: 10, retryDelayMs: 200 } + ); const unknownDocs = [ { type: 'space', id: 'space:default' }, diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.14.0_xpack_sample_saved_objects.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.14.0_xpack_sample_saved_objects.zip new file mode 100644 index 0000000000000..70d68587e3603 Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.14.0_xpack_sample_saved_objects.zip differ diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/batch_size_bytes.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/batch_size_bytes.test.ts new file mode 100644 index 0000000000000..e96aeb6a93b65 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/batch_size_bytes.test.ts @@ -0,0 +1,145 @@ +/* + * 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 Path from 'path'; +import fs from 'fs/promises'; +import JSON5 from 'json5'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import { Root } from '../../../root'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { Env } from '@kbn/config'; +import { REPO_ROOT } from '@kbn/utils'; +import { getEnvOptions } from '../../../config/mocks'; +import { LogRecord } from '@kbn/logging'; +import { retryAsync } from '../test_helpers/retry_async'; + +const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version; +const targetIndex = `.kibana_${kibanaVersion}_001`; +const logFilePath = Path.join(__dirname, 'batch_size_bytes.log'); + +async function removeLogFile() { + // ignore errors if it doesn't exist + await fs.unlink(logFilePath).catch(() => void 0); +} + +describe('migration v2', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let root: Root; + let startES: () => Promise; + + beforeAll(async () => { + await removeLogFile(); + }); + + beforeEach(() => { + ({ startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'basic', + dataArchive: Path.join(__dirname, 'archives', '7.14.0_xpack_sample_saved_objects.zip'), + esArgs: ['http.max_content_length=1715275b'], + }, + }, + })); + }); + + afterEach(async () => { + if (root) { + await root.shutdown(); + } + if (esServer) { + await esServer.stop(); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); + }); + + it('completes the migration even when a full batch would exceed ES http.max_content_length', async () => { + root = createRoot({ maxBatchSizeBytes: 1715275 }); + esServer = await startES(); + await root.preboot(); + await root.setup(); + await expect(root.start()).resolves.toBeTruthy(); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const esClient: ElasticsearchClient = esServer.es.getClient(); + const migratedIndexResponse = await esClient.count({ + index: targetIndex, + }); + const oldIndexResponse = await esClient.count({ + index: '.kibana_7.14.0_001', + }); + + // Use a >= comparison since once Kibana has started it might create new + // documents like telemetry tasks + expect(migratedIndexResponse.body.count).toBeGreaterThanOrEqual(oldIndexResponse.body.count); + }); + + it('fails with a descriptive message when a single document exceeds maxBatchSizeBytes', async () => { + root = createRoot({ maxBatchSizeBytes: 1015275 }); + esServer = await startES(); + await root.preboot(); + await root.setup(); + await expect(root.start()).rejects.toMatchInlineSnapshot( + `[Error: Unable to complete saved object migrations for the [.kibana] index: The document with _id "canvas-workpad-template:workpad-template-061d7868-2b4e-4dc8-8bf7-3772b52926e5" is 1715275 bytes which exceeds the configured maximum batch size of 1015275 bytes. To proceed, please increase the 'migrations.maxBatchSizeBytes' Kibana configuration option and ensure that the Elasticsearch 'http.max_content_length' configuration option is set to an equal or larger value.]` + ); + + await retryAsync( + async () => { + const logFileContent = await fs.readFile(logFilePath, 'utf-8'); + const records = logFileContent + .split('\n') + .filter(Boolean) + .map((str) => JSON5.parse(str)) as LogRecord[]; + expect( + records.find((rec) => + rec.message.startsWith( + `Unable to complete saved object migrations for the [.kibana] index: The document with _id "canvas-workpad-template:workpad-template-061d7868-2b4e-4dc8-8bf7-3772b52926e5" is 1715275 bytes which exceeds the configured maximum batch size of 1015275 bytes. To proceed, please increase the 'migrations.maxBatchSizeBytes' Kibana configuration option and ensure that the Elasticsearch 'http.max_content_length' configuration option is set to an equal or larger value.` + ) + ) + ).toBeDefined(); + }, + { retryAttempts: 10, retryDelayMs: 200 } + ); + }); +}); + +function createRoot(options: { maxBatchSizeBytes?: number }) { + return kbnTestServer.createRootWithCorePlugins( + { + migrations: { + skip: false, + enableV2: true, + batchSize: 1000, + maxBatchSizeBytes: options.maxBatchSizeBytes, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + ], + }, + }, + { + oss: true, + } + ); +} diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/batch_size_bytes_exceeds_es_content_length.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/batch_size_bytes_exceeds_es_content_length.test.ts new file mode 100644 index 0000000000000..192321227d4ae --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/batch_size_bytes_exceeds_es_content_length.test.ts @@ -0,0 +1,117 @@ +/* + * 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 Path from 'path'; +import fs from 'fs/promises'; +import JSON5 from 'json5'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import { Root } from '../../../root'; +import { retryAsync } from '../test_helpers/retry_async'; + +const logFilePath = Path.join(__dirname, 'batch_size_bytes_exceeds_es_content_length.log'); + +async function removeLogFile() { + // ignore errors if it doesn't exist + await fs.unlink(logFilePath).catch(() => void 0); +} + +describe('migration v2', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let root: Root; + let startES: () => Promise; + + beforeAll(async () => { + await removeLogFile(); + }); + + beforeEach(() => { + ({ startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'basic', + dataArchive: Path.join(__dirname, 'archives', '7.14.0_xpack_sample_saved_objects.zip'), + esArgs: ['http.max_content_length=1mb'], + }, + }, + })); + }); + + afterEach(async () => { + if (root) { + await root.shutdown(); + } + if (esServer) { + await esServer.stop(); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); + }); + + it('fails with a descriptive message when maxBatchSizeBytes exceeds ES http.max_content_length', async () => { + root = createRoot({ maxBatchSizeBytes: 1715275 }); + esServer = await startES(); + await root.preboot(); + await root.setup(); + await expect(root.start()).rejects.toMatchInlineSnapshot( + `[Error: Unable to complete saved object migrations for the [.kibana] index: While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Ensure that the Kibana configuration option 'migrations.maxBatchSizeBytes' is set to a value that is lower than or equal to the Elasticsearch 'http.max_content_length' configuration option.]` + ); + + await retryAsync( + async () => { + const logFileContent = await fs.readFile(logFilePath, 'utf-8'); + const records = logFileContent + .split('\n') + .filter(Boolean) + .map((str) => JSON5.parse(str)) as any[]; + + expect( + records.find((rec) => + rec.message.startsWith( + `Unable to complete saved object migrations for the [.kibana] index: While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Ensure that the Kibana configuration option 'migrations.maxBatchSizeBytes' is set to a value that is lower than or equal to the Elasticsearch 'http.max_content_length' configuration option.` + ) + ) + ).toBeDefined(); + }, + { retryAttempts: 10, retryDelayMs: 200 } + ); + }); +}); + +function createRoot(options: { maxBatchSizeBytes?: number }) { + return kbnTestServer.createRootWithCorePlugins( + { + migrations: { + skip: false, + enableV2: true, + batchSize: 1000, + maxBatchSizeBytes: options.maxBatchSizeBytes, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + ], + }, + }, + { + oss: true, + } + ); +} diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts index 684b75056bf44..bb408d14df6d7 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts @@ -13,7 +13,7 @@ import JSON5 from 'json5'; import * as kbnTestServer from '../../../../test_helpers/kbn_server'; import type { Root } from '../../../root'; -const logFilePath = Path.join(__dirname, 'cleanup_test.log'); +const logFilePath = Path.join(__dirname, 'cleanup.log'); const asyncUnlink = Util.promisify(Fs.unlink); const asyncReadFile = Util.promisify(Fs.readFile); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/type_migration_failure.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/collects_corrupt_docs.test.ts similarity index 98% rename from src/core/server/saved_objects/migrationsv2/integration_tests/type_migration_failure.test.ts rename to src/core/server/saved_objects/migrationsv2/integration_tests/collects_corrupt_docs.test.ts index b3721d603d7d9..02b7d0eae2a90 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/type_migration_failure.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/collects_corrupt_docs.test.ts @@ -12,7 +12,7 @@ import Util from 'util'; import * as kbnTestServer from '../../../../test_helpers/kbn_server'; import { Root } from '../../../root'; -const logFilePath = Path.join(__dirname, 'migration_test_corrupt_docs_kibana.log'); +const logFilePath = Path.join(__dirname, 'collects_corrupt_docs.log'); const asyncUnlink = Util.promisify(Fs.unlink); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/corrupt_outdated_docs.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/corrupt_outdated_docs.test.ts index de58dded69422..446542cc37306 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/corrupt_outdated_docs.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/corrupt_outdated_docs.test.ts @@ -12,7 +12,7 @@ import Util from 'util'; import * as kbnTestServer from '../../../../test_helpers/kbn_server'; import { Root } from '../../../root'; -const logFilePath = Path.join(__dirname, 'migration_test_corrupt_docs_kibana.log'); +const logFilePath = Path.join(__dirname, 'corrupt_outdated_docs.log'); const asyncUnlink = Util.promisify(Fs.unlink); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_from_v1.test.ts similarity index 99% rename from src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts rename to src/core/server/saved_objects/migrationsv2/integration_tests/migration_from_v1.test.ts index 2a1d6bff0c247..fc01e6a408497 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_from_v1.test.ts @@ -21,7 +21,7 @@ import { Root } from '../../../root'; const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version; -const logFilePath = Path.join(__dirname, 'migration_test_kibana_from_v1.log'); +const logFilePath = Path.join(__dirname, 'migration_from_v1.log'); const asyncUnlink = Util.promisify(Fs.unlink); async function removeLogFile() { diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/outdated_docs.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/outdated_docs.test.ts index 822a44fb22dc1..58ff34913f5d4 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/outdated_docs.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/outdated_docs.test.ts @@ -14,7 +14,7 @@ import * as kbnTestServer from '../../../../test_helpers/kbn_server'; import type { ElasticsearchClient } from '../../../elasticsearch'; import { Root } from '../../../root'; -const logFilePath = Path.join(__dirname, 'migration_test_kibana.log'); +const logFilePath = Path.join(__dirname, 'outdated_docs.log'); const asyncUnlink = Util.promisify(Fs.unlink); async function removeLogFile() { diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts index 0bdf7a0d98766..4564a89ee0816 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts @@ -15,7 +15,7 @@ import type { ElasticsearchClient } from '../../../elasticsearch'; import { Root } from '../../../root'; import { deterministicallyRegenerateObjectId } from '../../migrations/core/document_migrator'; -const logFilePath = Path.join(__dirname, 'migration_test_kibana.log'); +const logFilePath = Path.join(__dirname, 'rewriting_id.log'); const asyncUnlink = Util.promisify(Fs.unlink); async function removeLogFile() { diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts index 773a0af469bd4..a312ac6be0c3d 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts @@ -17,6 +17,7 @@ import { elasticsearchClientMock } from '../../elasticsearch/client/mocks'; import { LoggerAdapter } from '../../logging/logger_adapter'; import { AllControlStates, State } from './types'; import { createInitialState } from './initial_state'; +import { ByteSizeValue } from '@kbn/config-schema'; const esClient = elasticsearchServiceMock.createElasticsearchClient(); @@ -40,6 +41,7 @@ describe('migrationsStateActionMachine', () => { indexPrefix: '.my-so-index', migrationsConfig: { batchSize: 1000, + maxBatchSizeBytes: new ByteSizeValue(1e8), pollInterval: 0, scrollDuration: '0s', skip: false, @@ -235,6 +237,7 @@ describe('migrationsStateActionMachine', () => { ...initialState, reason: 'the fatal reason', outdatedDocuments: [{ _id: '1234', password: 'sensitive password' }], + transformedDocBatches: [[{ _id: '1234', password: 'sensitive transformed password' }]], } as State, logger: mockLogger.get(), model: transitionModel(['LEGACY_DELETE', 'FATAL']), @@ -257,6 +260,7 @@ describe('migrationsStateActionMachine', () => { kibana: { migrationState: { batchSize: 1000, + maxBatchSizeBytes: 1e8, controlState: 'LEGACY_DELETE', currentAlias: '.my-so-index', excludeFromUpgradeFilterHooks: {}, @@ -270,7 +274,7 @@ describe('migrationsStateActionMachine', () => { message: 'Log from LEGACY_DELETE control state', }, ], - outdatedDocuments: ['1234'], + outdatedDocuments: [{ _id: '1234' }], outdatedDocumentsQuery: expect.any(Object), preMigrationScript: { _tag: 'None', @@ -284,6 +288,7 @@ describe('migrationsStateActionMachine', () => { }, tempIndex: '.my-so-index_7.11.0_reindex_temp', tempIndexMappings: expect.any(Object), + transformedDocBatches: [[{ _id: '1234' }]], unusedTypesQuery: expect.any(Object), versionAlias: '.my-so-index_7.11.0', versionIndex: '.my-so-index_7.11.0_001', @@ -304,6 +309,7 @@ describe('migrationsStateActionMachine', () => { kibana: { migrationState: { batchSize: 1000, + maxBatchSizeBytes: 1e8, controlState: 'FATAL', currentAlias: '.my-so-index', excludeFromUpgradeFilterHooks: {}, @@ -321,7 +327,7 @@ describe('migrationsStateActionMachine', () => { message: 'Log from FATAL control state', }, ], - outdatedDocuments: ['1234'], + outdatedDocuments: [{ _id: '1234' }], outdatedDocumentsQuery: expect.any(Object), preMigrationScript: { _tag: 'None', @@ -335,6 +341,7 @@ describe('migrationsStateActionMachine', () => { }, tempIndex: '.my-so-index_7.11.0_reindex_temp', tempIndexMappings: expect.any(Object), + transformedDocBatches: [[{ _id: '1234' }]], unusedTypesQuery: expect.any(Object), versionAlias: '.my-so-index_7.11.0', versionIndex: '.my-so-index_7.11.0_001', @@ -447,6 +454,7 @@ describe('migrationsStateActionMachine', () => { kibana: { migrationState: { batchSize: 1000, + maxBatchSizeBytes: 1e8, controlState: 'LEGACY_REINDEX', currentAlias: '.my-so-index', excludeFromUpgradeFilterHooks: {}, @@ -474,6 +482,7 @@ describe('migrationsStateActionMachine', () => { }, tempIndex: '.my-so-index_7.11.0_reindex_temp', tempIndexMappings: expect.any(Object), + transformedDocBatches: [], unusedTypesQuery: expect.any(Object), versionAlias: '.my-so-index_7.11.0', versionIndex: '.my-so-index_7.11.0_001', @@ -488,6 +497,7 @@ describe('migrationsStateActionMachine', () => { kibana: { migrationState: { batchSize: 1000, + maxBatchSizeBytes: 1e8, controlState: 'LEGACY_DELETE', currentAlias: '.my-so-index', excludeFromUpgradeFilterHooks: {}, @@ -519,6 +529,7 @@ describe('migrationsStateActionMachine', () => { }, tempIndex: '.my-so-index_7.11.0_reindex_temp', tempIndexMappings: expect.any(Object), + transformedDocBatches: [], unusedTypesQuery: expect.any(Object), versionAlias: '.my-so-index_7.11.0', versionIndex: '.my-so-index_7.11.0_001', diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts index 8e3b8ee4ab556..58c299b77fc60 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts @@ -13,7 +13,8 @@ import type { ElasticsearchClient } from '../../elasticsearch'; import { getErrorMessage, getRequestDebugMeta } from '../../elasticsearch'; import { Model, Next, stateActionMachine } from './state_action_machine'; import { cleanup } from './migrations_state_machine_cleanup'; -import { State } from './types'; +import { ReindexSourceToTempIndex, ReindexSourceToTempIndexBulk, State } from './types'; +import { SavedObjectsRawDoc } from '../serialization'; interface StateLogMeta extends LogMeta { kibana: { @@ -140,11 +141,22 @@ export async function migrationStateActionMachine({ const newState = model(state, res); // Redact the state to reduce the memory consumption and so that we // don't log sensitive information inside documents by only keeping - // the _id's of outdatedDocuments + // the _id's of documents const redactedNewState = { ...newState, - // @ts-expect-error outdatedDocuments don't exist in all states - ...{ outdatedDocuments: (newState.outdatedDocuments ?? []).map((doc) => doc._id) }, + ...{ + outdatedDocuments: ((newState as ReindexSourceToTempIndex).outdatedDocuments ?? []).map( + (doc) => + ({ + _id: doc._id, + } as SavedObjectsRawDoc) + ), + }, + ...{ + transformedDocBatches: ( + (newState as ReindexSourceToTempIndexBulk).transformedDocBatches ?? [] + ).map((batches) => batches.map((doc) => ({ _id: doc._id }))) as [SavedObjectsRawDoc[]], + }, }; executionLog.push({ type: 'transition', diff --git a/src/core/server/saved_objects/migrationsv2/model/create_batches.test.ts b/src/core/server/saved_objects/migrationsv2/model/create_batches.test.ts new file mode 100644 index 0000000000000..552c4c237675f --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/model/create_batches.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 * as Either from 'fp-ts/lib/Either'; +import { SavedObjectsRawDoc } from '../../serialization'; +import { createBatches } from './create_batches'; + +describe('createBatches', () => { + const DOCUMENT_SIZE_BYTES = 128; + const INDEX = '.kibana_version_index'; + it('returns right one batch if all documents fit in maxBatchSizeBytes', () => { + const documents = [ + { _id: '', _source: { type: 'dashboard', title: 'my saved object title ¹' } }, + { _id: '', _source: { type: 'dashboard', title: 'my saved object title ²' } }, + { _id: '', _source: { type: 'dashboard', title: 'my saved object title ®' } }, + ]; + + expect(createBatches(documents, INDEX, DOCUMENT_SIZE_BYTES * 3)).toEqual( + Either.right([documents]) + ); + }); + it('creates multiple batches with each batch limited to maxBatchSizeBytes', () => { + const documents = [ + { _id: '', _source: { type: 'dashboard', title: 'my saved object title ¹' } }, + { _id: '', _source: { type: 'dashboard', title: 'my saved object title ²' } }, + { _id: '', _source: { type: 'dashboard', title: 'my saved object title ®' } }, + { _id: '', _source: { type: 'dashboard', title: 'my saved object title 44' } }, + { _id: '', _source: { type: 'dashboard', title: 'my saved object title 55' } }, + ]; + expect(createBatches(documents, INDEX, DOCUMENT_SIZE_BYTES * 2)).toEqual( + Either.right([[documents[0], documents[1]], [documents[2], documents[3]], [documents[4]]]) + ); + }); + it('creates a single empty batch if there are no documents', () => { + const documents = [] as SavedObjectsRawDoc[]; + expect(createBatches(documents, INDEX, 100)).toEqual(Either.right([[]])); + }); + it('throws if any one document exceeds the maxBatchSizeBytes', () => { + const documents = [ + { _id: '', _source: { type: 'dashboard', title: 'my saved object title ¹' } }, + { + _id: '', + _source: { + type: 'dashboard', + title: 'my saved object title ² with a very long title that exceeds max size bytes', + }, + }, + { _id: '', _source: { type: 'dashboard', title: 'my saved object title ®' } }, + ]; + expect(createBatches(documents, INDEX, 178)).toEqual( + Either.left({ + maxBatchSizeBytes: 178, + docSizeBytes: 179, + type: 'document_exceeds_batch_size_bytes', + document: documents[1], + }) + ); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/model/create_batches.ts b/src/core/server/saved_objects/migrationsv2/model/create_batches.ts new file mode 100644 index 0000000000000..c80003fef09fb --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/model/create_batches.ts @@ -0,0 +1,66 @@ +/* + * 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 * as Either from 'fp-ts/lib/Either'; +import { SavedObjectsRawDoc } from '../..'; +import { createBulkOperationBody } from '../actions/bulk_overwrite_transformed_documents'; + +/** + * Creates batches of documents to be used by the bulk API. Each batch will + * have a request body content length that's <= maxBatchSizeBytes + */ +export function createBatches( + docs: SavedObjectsRawDoc[], + index: string, + maxBatchSizeBytes: number +) { + /* To build up the NDJSON request body we construct an array of objects like: + * [ + * {"index": ...} + * {"title": "my saved object"} + * ... + * ] + * However, when we call JSON.stringify on this array the resulting string + * will be surrounded by `[]` which won't be present in the NDJSON so these + * two characters need to be removed from the size calculation. + */ + const BRACKETS_BYTES = 2; + /* Each document in the NDJSON (including the last one) needs to be + * terminated by a newline, so we need to account for an extra newline + * character + */ + const NDJSON_NEW_LINE_BYTES = 1; + + const batches = [[]] as [SavedObjectsRawDoc[]]; + let currBatch = 0; + let currBatchSizeBytes = 0; + for (const doc of docs) { + const bulkOperationBody = createBulkOperationBody(doc, index); + const docSizeBytes = + Buffer.byteLength(JSON.stringify(bulkOperationBody), 'utf8') - + BRACKETS_BYTES + + NDJSON_NEW_LINE_BYTES; + if (docSizeBytes > maxBatchSizeBytes) { + return Either.left({ + type: 'document_exceeds_batch_size_bytes', + docSizeBytes, + maxBatchSizeBytes, + document: doc, + }); + } else if (currBatchSizeBytes + docSizeBytes <= maxBatchSizeBytes) { + batches[currBatch].push(doc); + currBatchSizeBytes = currBatchSizeBytes + docSizeBytes; + } else { + currBatch++; + batches[currBatch] = [doc]; + currBatchSizeBytes = docSizeBytes; + } + } + + return Either.right(batches); +} diff --git a/src/core/server/saved_objects/migrationsv2/model/model.test.ts b/src/core/server/saved_objects/migrationsv2/model/model.test.ts index f24d175f416a7..1d017116bf3fd 100644 --- a/src/core/server/saved_objects/migrationsv2/model/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model/model.test.ts @@ -58,6 +58,7 @@ describe('migrations v2 model', () => { retryDelay: 0, retryAttempts: 15, batchSize: 1000, + maxBatchSizeBytes: 1e8, indexPrefix: '.kibana', outdatedDocumentsQuery: {}, targetIndexMappings: { @@ -1065,6 +1066,8 @@ describe('migrations v2 model', () => { }); const newState = model(state, res) as ReindexSourceToTempIndexBulk; expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_INDEX_BULK'); + expect(newState.currentBatch).toEqual(0); + expect(newState.transformedDocBatches).toEqual([processedDocs]); expect(newState.progress.processed).toBe(0); // Result of `(undefined ?? 0) + corruptDocumentsId.length` }); @@ -1119,16 +1122,19 @@ describe('migrations v2 model', () => { }); describe('REINDEX_SOURCE_TO_TEMP_INDEX_BULK', () => { - const transformedDocs = [ - { - _id: 'a:b', - _source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {}, references: [] }, - }, - ] as SavedObjectsRawDoc[]; + const transformedDocBatches = [ + [ + { + _id: 'a:b', + _source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {}, references: [] }, + }, + ], + ] as [SavedObjectsRawDoc[]]; const reindexSourceToTempIndexBulkState: ReindexSourceToTempIndexBulk = { ...baseState, controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX_BULK', - transformedDocs, + transformedDocBatches, + currentBatch: 0, versionIndexReadyActions: Option.none, sourceIndex: Option.some('.kibana') as Option.Some, sourceIndexPitId: 'pit_id', @@ -1171,7 +1177,7 @@ describe('migrations v2 model', () => { const newState = model(reindexSourceToTempIndexBulkState, res) as FatalState; expect(newState.controlState).toEqual('FATAL'); expect(newState.reason).toMatchInlineSnapshot( - `"While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Try to use smaller batches by changing the Kibana 'migrations.batchSize' configuration option and restarting Kibana."` + `"While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Ensure that the Kibana configuration option 'migrations.maxBatchSizeBytes' is set to a value that is lower than or equal to the Elasticsearch 'http.max_content_length' configuration option."` ); }); test('REINDEX_SOURCE_TO_TEMP_INDEX_BULK should throw a throwBadResponse error if action failed', () => { @@ -1438,7 +1444,8 @@ describe('migrations v2 model', () => { res ) as TransformedDocumentsBulkIndex; expect(newState.controlState).toEqual('TRANSFORMED_DOCUMENTS_BULK_INDEX'); - expect(newState.transformedDocs).toEqual(processedDocs); + expect(newState.transformedDocBatches).toEqual([processedDocs]); + expect(newState.currentBatch).toEqual(0); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); expect(newState.progress.processed).toBe(outdatedDocuments.length); @@ -1521,16 +1528,31 @@ describe('migrations v2 model', () => { }); describe('TRANSFORMED_DOCUMENTS_BULK_INDEX', () => { - const transformedDocs = [ - { - _id: 'a:b', - _source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {}, references: [] }, - }, - ] as SavedObjectsRawDoc[]; + const transformedDocBatches = [ + [ + // batch 0 + { + _id: 'a:b', + _source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {}, references: [] }, + }, + { + _id: 'a:c', + _source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {}, references: [] }, + }, + ], + [ + // batch 1 + { + _id: 'a:d', + _source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {}, references: [] }, + }, + ], + ] as SavedObjectsRawDoc[][]; const transformedDocumentsBulkIndexState: TransformedDocumentsBulkIndex = { ...baseState, controlState: 'TRANSFORMED_DOCUMENTS_BULK_INDEX', - transformedDocs, + transformedDocBatches, + currentBatch: 0, versionIndexReadyActions: Option.none, sourceIndex: Option.some('.kibana') as Option.Some, targetIndex: '.kibana_7.11.0_001', @@ -1540,6 +1562,29 @@ describe('migrations v2 model', () => { progress: createInitialProgress(), }; + test('TRANSFORMED_DOCUMENTS_BULK_INDEX -> TRANSFORMED_DOCUMENTS_BULK_INDEX and increments currentBatch if more batches are left', () => { + const res: ResponseType<'TRANSFORMED_DOCUMENTS_BULK_INDEX'> = Either.right( + 'bulk_index_succeeded' + ); + const newState = model( + transformedDocumentsBulkIndexState, + res + ) as TransformedDocumentsBulkIndex; + expect(newState.controlState).toEqual('TRANSFORMED_DOCUMENTS_BULK_INDEX'); + expect(newState.currentBatch).toEqual(1); + }); + + test('TRANSFORMED_DOCUMENTS_BULK_INDEX -> OUTDATED_DOCUMENTS_SEARCH_READ if all batches were written', () => { + const res: ResponseType<'TRANSFORMED_DOCUMENTS_BULK_INDEX'> = Either.right( + 'bulk_index_succeeded' + ); + const newState = model( + { ...transformedDocumentsBulkIndexState, ...{ currentBatch: 1 } }, + res + ); + expect(newState.controlState).toEqual('OUTDATED_DOCUMENTS_SEARCH_READ'); + }); + test('TRANSFORMED_DOCUMENTS_BULK_INDEX throws if action returns left index_not_found_exception', () => { const res: ResponseType<'TRANSFORMED_DOCUMENTS_BULK_INDEX'> = Either.left({ type: 'index_not_found_exception', @@ -1570,7 +1615,7 @@ describe('migrations v2 model', () => { const newState = model(transformedDocumentsBulkIndexState, res) as FatalState; expect(newState.controlState).toEqual('FATAL'); expect(newState.reason).toMatchInlineSnapshot( - `"While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Try to use smaller batches by changing the Kibana 'migrations.batchSize' configuration option and restarting Kibana."` + `"While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Ensure that the Kibana configuration option 'migrations.maxBatchSizeBytes' is set to a value that is lower than or equal to the Elasticsearch 'http.max_content_length' configuration option."` ); }); }); diff --git a/src/core/server/saved_objects/migrationsv2/model/model.ts b/src/core/server/saved_objects/migrationsv2/model/model.ts index 50be4a524f5c5..8aa3d7b83b295 100644 --- a/src/core/server/saved_objects/migrationsv2/model/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model/model.ts @@ -31,6 +31,19 @@ import { throwBadControlState, throwBadResponse, } from './helpers'; +import { createBatches } from './create_batches'; + +const FATAL_REASON_REQUEST_ENTITY_TOO_LARGE = `While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Ensure that the Kibana configuration option 'migrations.maxBatchSizeBytes' is set to a value that is lower than or equal to the Elasticsearch 'http.max_content_length' configuration option.`; +const fatalReasonDocumentExceedsMaxBatchSizeBytes = ({ + _id, + docSizeBytes, + maxBatchSizeBytes, +}: { + _id: string; + docSizeBytes: number; + maxBatchSizeBytes: number; +}) => + `The document with _id "${_id}" is ${docSizeBytes} bytes which exceeds the configured maximum batch size of ${maxBatchSizeBytes} bytes. To proceed, please increase the 'migrations.maxBatchSizeBytes' Kibana configuration option and ensure that the Elasticsearch 'http.max_content_length' configuration option is set to an equal or larger value.`; export const model = (currentState: State, resW: ResponseType): State => { // The action response `resW` is weakly typed, the type includes all action @@ -489,12 +502,30 @@ export const model = (currentState: State, resW: ResponseType): if (Either.isRight(res)) { if (stateP.corruptDocumentIds.length === 0 && stateP.transformErrors.length === 0) { - return { - ...stateP, - controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX_BULK', // handles the actual bulk indexing into temp index - transformedDocs: [...res.right.processedDocs], - progress, - }; + const batches = createBatches( + res.right.processedDocs, + stateP.tempIndex, + stateP.maxBatchSizeBytes + ); + if (Either.isRight(batches)) { + return { + ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX_BULK', // handles the actual bulk indexing into temp index + transformedDocBatches: batches.right, + currentBatch: 0, + progress, + }; + } else { + return { + ...stateP, + controlState: 'FATAL', + reason: fatalReasonDocumentExceedsMaxBatchSizeBytes({ + _id: batches.left.document._id, + docSizeBytes: batches.left.docSizeBytes, + maxBatchSizeBytes: batches.left.maxBatchSizeBytes, + }), + }; + } } else { // we don't have any transform issues with the current batch of outdated docs but // we have carried through previous transformation issues. @@ -525,13 +556,21 @@ export const model = (currentState: State, resW: ResponseType): } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_INDEX_BULK') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { - return { - ...stateP, - controlState: 'REINDEX_SOURCE_TO_TEMP_READ', - // we're still on the happy path with no transformation failures seen. - corruptDocumentIds: [], - transformErrors: [], - }; + if (stateP.currentBatch + 1 < stateP.transformedDocBatches.length) { + return { + ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX_BULK', + currentBatch: stateP.currentBatch + 1, + }; + } else { + return { + ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', + // we're still on the happy path with no transformation failures seen. + corruptDocumentIds: [], + transformErrors: [], + }; + } } else { if ( isLeftTypeof(res.left, 'target_index_had_write_block') || @@ -548,7 +587,7 @@ export const model = (currentState: State, resW: ResponseType): return { ...stateP, controlState: 'FATAL', - reason: `While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Try to use smaller batches by changing the Kibana 'migrations.batchSize' configuration option and restarting Kibana.`, + reason: FATAL_REASON_REQUEST_ENTITY_TOO_LARGE, }; } throwBadResponse(stateP, res.left); @@ -677,13 +716,31 @@ export const model = (currentState: State, resW: ResponseType): // we haven't seen corrupt documents or any transformation errors thus far in the migration // index the migrated docs if (stateP.corruptDocumentIds.length === 0 && stateP.transformErrors.length === 0) { - return { - ...stateP, - controlState: 'TRANSFORMED_DOCUMENTS_BULK_INDEX', - transformedDocs: [...res.right.processedDocs], - hasTransformedDocs: true, - progress, - }; + const batches = createBatches( + res.right.processedDocs, + stateP.targetIndex, + stateP.maxBatchSizeBytes + ); + if (Either.isRight(batches)) { + return { + ...stateP, + controlState: 'TRANSFORMED_DOCUMENTS_BULK_INDEX', + transformedDocBatches: batches.right, + currentBatch: 0, + hasTransformedDocs: true, + progress, + }; + } else { + return { + ...stateP, + controlState: 'FATAL', + reason: fatalReasonDocumentExceedsMaxBatchSizeBytes({ + _id: batches.left.document._id, + docSizeBytes: batches.left.docSizeBytes, + maxBatchSizeBytes: batches.left.maxBatchSizeBytes, + }), + }; + } } else { // We have seen corrupt documents and/or transformation errors // skip indexing and go straight to reading and transforming more docs @@ -711,6 +768,13 @@ export const model = (currentState: State, resW: ResponseType): } else if (stateP.controlState === 'TRANSFORMED_DOCUMENTS_BULK_INDEX') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { + if (stateP.currentBatch + 1 < stateP.transformedDocBatches.length) { + return { + ...stateP, + controlState: 'TRANSFORMED_DOCUMENTS_BULK_INDEX', + currentBatch: stateP.currentBatch + 1, + }; + } return { ...stateP, controlState: 'OUTDATED_DOCUMENTS_SEARCH_READ', @@ -723,7 +787,7 @@ export const model = (currentState: State, resW: ResponseType): return { ...stateP, controlState: 'FATAL', - reason: `While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Try to use smaller batches by changing the Kibana 'migrations.batchSize' configuration option and restarting Kibana.`, + reason: FATAL_REASON_REQUEST_ENTITY_TOO_LARGE, }; } else if ( isLeftTypeof(res.left, 'target_index_had_write_block') || diff --git a/src/core/server/saved_objects/migrationsv2/next.ts b/src/core/server/saved_objects/migrationsv2/next.ts index 9b091b6fc8509..3f3714552725b 100644 --- a/src/core/server/saved_objects/migrationsv2/next.ts +++ b/src/core/server/saved_objects/migrationsv2/next.ts @@ -111,7 +111,7 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra Actions.bulkOverwriteTransformedDocuments({ client, index: state.tempIndex, - transformedDocs: state.transformedDocs, + transformedDocs: state.transformedDocBatches[state.currentBatch], /** * Since we don't run a search against the target index, we disable "refresh" to speed up * the migration process. @@ -160,7 +160,7 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra Actions.bulkOverwriteTransformedDocuments({ client, index: state.targetIndex, - transformedDocs: state.transformedDocs, + transformedDocs: state.transformedDocBatches[state.currentBatch], /** * Since we don't run a search against the target index, we disable "refresh" to speed up * the migration process. 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 new file mode 100644 index 0000000000000..246f61c71ae4d --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/test_helpers/retry.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { retryAsync } from './retry_async'; + +describe('retry', () => { + it('retries throwing functions until they succeed', async () => { + let i = 0; + await expect( + retryAsync( + () => { + if (i++ < 2) { + return Promise.reject(new Error('boom')); + } else { + return Promise.resolve('done'); + } + }, + { retryAttempts: 3, retryDelayMs: 1 } + ) + ).resolves.toEqual('done'); + }); + + it('throws if all attempts are exhausted before success', async () => { + let attempts = 0; + await expect(() => + retryAsync( + () => { + attempts++; + return Promise.reject(new Error('boom')); + }, + { retryAttempts: 3, retryDelayMs: 1 } + ) + ).rejects.toMatchInlineSnapshot(`[Error: boom]`); + expect(attempts).toEqual(3); + }); + + it('waits retryDelayMs between each attempt ', async () => { + const now = Date.now(); + let i = 0; + await retryAsync( + () => { + if (i++ < 2) { + return Promise.reject(new Error('boom')); + } else { + return Promise.resolve('done'); + } + }, + { retryAttempts: 3, retryDelayMs: 100 } + ); + expect(Date.now() - now).toBeGreaterThanOrEqual(200); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/test_helpers/retry_async.ts b/src/core/server/saved_objects/migrationsv2/test_helpers/retry_async.ts new file mode 100644 index 0000000000000..f5dffede67a16 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/test_helpers/retry_async.ts @@ -0,0 +1,30 @@ +/* + * 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. + */ + +function delay(delayInMs: number) { + return new Promise((resolve) => setTimeout(resolve, delayInMs)); +} + +export async function retryAsync( + fn: () => Promise, + options: { retryAttempts: number; retryDelayMs: number } +): Promise { + try { + return await fn(); + } catch (e) { + if (options.retryAttempts > 1) { + await delay(options.retryDelayMs); + return retryAsync(fn, { + retryAttempts: options.retryAttempts - 1, + retryDelayMs: options.retryDelayMs, + }); + } else { + throw e; + } + } +} diff --git a/src/core/server/saved_objects/migrationsv2/types.ts b/src/core/server/saved_objects/migrationsv2/types.ts index ea03b64e03dc8..49ce12c53aa1a 100644 --- a/src/core/server/saved_objects/migrationsv2/types.ts +++ b/src/core/server/saved_objects/migrationsv2/types.ts @@ -76,19 +76,31 @@ export interface BaseState extends ControlState { readonly retryAttempts: number; /** - * The number of documents to fetch from Elasticsearch server to run migration over. + * The number of documents to process in each batch. This determines the + * maximum number of documents that will be read and written in a single + * request. * - * The higher the value, the faster the migration process will be performed since it reduces - * the number of round trips between Kibana and Elasticsearch servers. - * For the migration speed, we have to pay the price of increased memory consumption. + * The higher the value, the faster the migration process will be performed + * since it reduces the number of round trips between Kibana and + * Elasticsearch servers. For the migration speed, we have to pay the price + * of increased memory consumption and HTTP payload size. * - * Since batchSize defines the number of documents, not their size, it might happen that - * Elasticsearch fails a request with circuit_breaking_exception when it retrieves a set of - * saved objects of significant size. + * Since we cannot control the size in bytes of a batch when reading, + * Elasticsearch might fail with a circuit_breaking_exception when it + * retrieves a set of saved objects of significant size. In this case, you + * should set a smaller batchSize value and restart the migration process + * again. * - * In this case, you should set a smaller batchSize value and restart the migration process again. + * When writing batches, we limit the number of documents in a batch + * (batchSize) as well as the size of the batch in bytes (maxBatchSizeBytes). */ readonly batchSize: number; + /** + * When writing batches, limits the batch size in bytes to ensure that we + * don't construct HTTP requests which would exceed Elasticsearch's + * http.max_content_length which defaults to 100mb. + */ + readonly maxBatchSizeBytes: number; readonly logs: MigrationLog[]; /** * The current alias e.g. `.kibana` which always points to the latest @@ -233,7 +245,8 @@ export interface ReindexSourceToTempIndex extends PostInitState { export interface ReindexSourceToTempIndexBulk extends PostInitState { readonly controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX_BULK'; - readonly transformedDocs: SavedObjectsRawDoc[]; + readonly transformedDocBatches: [SavedObjectsRawDoc[]]; + readonly currentBatch: number; readonly sourceIndexPitId: string; readonly lastHitSortValue: number[] | undefined; readonly progress: Progress; @@ -318,7 +331,8 @@ export interface TransformedDocumentsBulkIndex extends PostInitState { * Write the up-to-date transformed documents to the target index */ readonly controlState: 'TRANSFORMED_DOCUMENTS_BULK_INDEX'; - readonly transformedDocs: SavedObjectsRawDoc[]; + readonly transformedDocBatches: SavedObjectsRawDoc[][]; + readonly currentBatch: number; readonly lastHitSortValue: number[] | undefined; readonly hasTransformedDocs: boolean; readonly pitId: string; diff --git a/src/core/server/saved_objects/saved_objects_config.ts b/src/core/server/saved_objects/saved_objects_config.ts index c62d322f0bf8d..e7bbd706762f5 100644 --- a/src/core/server/saved_objects/saved_objects_config.ts +++ b/src/core/server/saved_objects/saved_objects_config.ts @@ -12,6 +12,7 @@ import type { ConfigDeprecationProvider } from '../config'; const migrationSchema = schema.object({ batchSize: schema.number({ defaultValue: 1_000 }), + maxBatchSizeBytes: schema.byteSize({ defaultValue: '100mb' }), // 100mb is the default http.max_content_length Elasticsearch config value scrollDuration: schema.string({ defaultValue: '15m' }), pollInterval: schema.number({ defaultValue: 1_500 }), skip: schema.boolean({ defaultValue: false }), diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 333ef8e7bf34c..aa421fe393059 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -293,6 +293,7 @@ export interface CoreConfigUsageData { }; apiVersion: string; healthCheckDelayMs: number; + principal: 'elastic_user' | 'kibana_user' | 'kibana_system_user' | 'other_user' | 'kibana_service_account' | 'unknown'; }; // (undocumented) http: { @@ -754,10 +755,10 @@ export interface DeprecationsDetails { // (undocumented) documentationUrl?: string; level: 'warning' | 'critical' | 'fetch_error'; - // (undocumented) message: string; // (undocumented) requireRestart?: boolean; + title: string; } // @public diff --git a/src/dev/build/build_distributables.ts b/src/dev/build/build_distributables.ts index 9ddf02e101a19..1042cdc484c12 100644 --- a/src/dev/build/build_distributables.ts +++ b/src/dev/build/build_distributables.ts @@ -105,6 +105,10 @@ export async function buildDistributables(log: ToolingLog, options: BuildOptions // control w/ --skip-archives await run(Tasks.CreateArchives); } + + if (options.createDebPackage || options.createRpmPackage) { + await run(Tasks.CreatePackageConfig); + } if (options.createDebPackage) { // control w/ --deb or --skip-os-packages await run(Tasks.CreateDebPackage); diff --git a/src/dev/build/tasks/os_packages/create_os_package_kibana_yml.ts b/src/dev/build/tasks/os_packages/create_os_package_kibana_yml.ts new file mode 100644 index 0000000000000..e7137ada02182 --- /dev/null +++ b/src/dev/build/tasks/os_packages/create_os_package_kibana_yml.ts @@ -0,0 +1,50 @@ +/* + * 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 { readFileSync, writeFileSync } from 'fs'; +import { resolve } from 'path'; +import { Build, Config, mkdirp } from '../../lib'; + +export async function createOSPackageKibanaYML(config: Config, build: Build) { + const configReadPath = config.resolveFromRepo('config', 'kibana.yml'); + const configWriteDir = config.resolveFromRepo('build', 'os_packages', 'config'); + const configWritePath = resolve(configWriteDir, 'kibana.yml'); + + await mkdirp(configWriteDir); + + let kibanaYML = readFileSync(configReadPath, { encoding: 'utf8' }); + + [ + [/#pid.file:.*/g, 'pid.file: /run/kibana/kibana.pid'], + [/#logging.dest:.*/g, 'logging.dest: /var/log/kibana/kibana.log'], + ].forEach((options) => { + const [regex, setting] = options; + const diff = kibanaYML; + const match = kibanaYML.search(regex) >= 0; + if (match) { + if (typeof setting === 'string') { + kibanaYML = kibanaYML.replace(regex, setting); + } + } + + if (!diff.localeCompare(kibanaYML)) { + throw new Error( + `OS package configuration unmodified. Verify match for ${regex} is available` + ); + } + }); + + try { + writeFileSync(configWritePath, kibanaYML, { flag: 'wx' }); + } catch (err) { + if (err.code === 'EEXIST') { + return; + } + throw err; + } +} diff --git a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts index 99d0e1998e78a..67a9e86ee2073 100644 --- a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts +++ b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts @@ -9,6 +9,15 @@ import { Task } from '../../lib'; import { runFpm } from './run_fpm'; import { runDockerGenerator } from './docker_generator'; +import { createOSPackageKibanaYML } from './create_os_package_kibana_yml'; + +export const CreatePackageConfig: Task = { + description: 'Creating OS package kibana.yml', + + async run(config, log, build) { + await createOSPackageKibanaYML(config, build); + }, +}; export const CreateDebPackage: Task = { description: 'Creating deb package', diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index c883e0b68114e..0af087f1427d7 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -108,6 +108,7 @@ kibana_vars=( map.tilemap.options.subdomains map.tilemap.url migrations.batchSize + migrations.maxBatchSizeBytes migrations.enableV2 migrations.pollInterval migrations.retryAttempts diff --git a/src/dev/build/tasks/os_packages/run_fpm.ts b/src/dev/build/tasks/os_packages/run_fpm.ts index b732e4c80ea37..c7d9f6997cdf2 100644 --- a/src/dev/build/tasks/os_packages/run_fpm.ts +++ b/src/dev/build/tasks/os_packages/run_fpm.ts @@ -123,6 +123,7 @@ export async function runFpm( `${resolveWithTrailingSlash(fromBuild('.'))}=/usr/share/kibana/`, // copy the config directory to /etc/kibana + `${config.resolveFromRepo('build/os_packages/config/kibana.yml')}=/etc/kibana/kibana.yml`, `${resolveWithTrailingSlash(fromBuild('config'))}=/etc/kibana/`, // copy the data directory at /var/lib/kibana diff --git a/src/dev/build/tasks/os_packages/service_templates/systemd/usr/lib/systemd/system/kibana.service b/src/dev/build/tasks/os_packages/service_templates/systemd/usr/lib/systemd/system/kibana.service index 7a1508d91b213..df33b82f1f967 100644 --- a/src/dev/build/tasks/os_packages/service_templates/systemd/usr/lib/systemd/system/kibana.service +++ b/src/dev/build/tasks/os_packages/service_templates/systemd/usr/lib/systemd/system/kibana.service @@ -15,7 +15,7 @@ Environment=KBN_PATH_CONF=/etc/kibana EnvironmentFile=-/etc/default/kibana EnvironmentFile=-/etc/sysconfig/kibana -ExecStart=/usr/share/kibana/bin/kibana --logging.dest="/var/log/kibana/kibana.log" --pid.file="/run/kibana/kibana.pid" +ExecStart=/usr/share/kibana/bin/kibana Restart=on-failure RestartSec=3 diff --git a/src/dev/build/tasks/package_json/find_used_dependencies.ts b/src/dev/build/tasks/package_json/find_used_dependencies.ts index 004e17b87ac8b..8cb8b3c986de7 100644 --- a/src/dev/build/tasks/package_json/find_used_dependencies.ts +++ b/src/dev/build/tasks/package_json/find_used_dependencies.ts @@ -29,9 +29,9 @@ export async function findUsedDependencies(listedPkgDependencies: any, baseDir: ]; const discoveredPluginEntries = await globby([ - normalize(Path.resolve(baseDir, `src/plugins/*/server/index.js`)), + normalize(Path.resolve(baseDir, `src/plugins/**/server/index.js`)), `!${normalize(Path.resolve(baseDir, `/src/plugins/**/public`))}`, - normalize(Path.resolve(baseDir, `x-pack/plugins/*/server/index.js`)), + normalize(Path.resolve(baseDir, `x-pack/plugins/**/server/index.js`)), `!${normalize(Path.resolve(baseDir, `/x-pack/plugins/**/public`))}`, ]); diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index cb7e3781e2511..ee355d6a9811b 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -75,7 +75,7 @@ export const LICENSE_OVERRIDES = { '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint 'node-sql-parser@3.6.1': ['(GPL-2.0 OR MIT)'], // GPL-2.0* https://github.com/taozhi8833998/node-sql-parser '@elastic/ems-client@7.15.0': ['Elastic License 2.0'], - '@elastic/eui@37.3.0': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@37.3.1': ['SSPL-1.0 OR Elastic License 2.0'], // TODO can be removed if the https://github.com/jindw/xmldom/issues/239 is released 'xmldom@0.1.27': ['MIT'], diff --git a/src/plugins/advanced_settings/kibana.json b/src/plugins/advanced_settings/kibana.json index cf00241ee2766..7562b6a660193 100644 --- a/src/plugins/advanced_settings/kibana.json +++ b/src/plugins/advanced_settings/kibana.json @@ -7,7 +7,7 @@ "optionalPlugins": ["home", "usageCollection"], "requiredBundles": ["kibanaReact", "kibanaUtils", "home", "esUiShared"], "owner": { - "name": "Kibana App", - "githubTeam": "kibana-app" + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" } } diff --git a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap index be5163e89367c..9249f5f98e9c9 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap +++ b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap @@ -1326,7 +1326,12 @@ exports[`Field for image setting should render as read only if saving is disable disabled={true} display="large" fullWidth={true} - initialPromptText="Select or drag and drop a file" + initialPromptText={ + + } onChange={[Function]} /> @@ -1472,7 +1477,12 @@ exports[`Field for image setting should render custom setting icon if it is cust disabled={false} display="large" fullWidth={true} - initialPromptText="Select or drag and drop a file" + initialPromptText={ + + } onChange={[Function]} /> @@ -1526,7 +1536,12 @@ exports[`Field for image setting should render default value if there is no user disabled={false} display="large" fullWidth={true} - initialPromptText="Select or drag and drop a file" + initialPromptText={ + + } onChange={[Function]} /> @@ -1597,7 +1612,12 @@ exports[`Field for image setting should render unsaved value if there are unsave disabled={false} display="large" fullWidth={true} - initialPromptText="Select or drag and drop a file" + initialPromptText={ + + } onChange={[Function]} /> diff --git a/src/plugins/bfetch/common/index.ts b/src/plugins/bfetch/common/index.ts index 9bf326eb4b6e5..b2b02e9ae3ed3 100644 --- a/src/plugins/bfetch/common/index.ts +++ b/src/plugins/bfetch/common/index.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +// TODO: https://github.com/elastic/kibana/issues/109905 +/* eslint-disable @kbn/eslint/no_export_all */ + export * from './util'; export * from './streaming'; export * from './buffer'; diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/index.ts b/src/plugins/chart_expressions/expression_tagcloud/common/index.ts index d8989abcc3d6f..cc4c141a73722 100755 --- a/src/plugins/chart_expressions/expression_tagcloud/common/index.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/index.ts @@ -6,4 +6,7 @@ * Side Public License, v 1. */ +// TODO: https://github.com/elastic/kibana/issues/110891 +/* eslint-disable @kbn/eslint/no_export_all */ + export * from './constants'; diff --git a/src/plugins/chart_expressions/expression_tagcloud/kibana.json b/src/plugins/chart_expressions/expression_tagcloud/kibana.json index 26d5ef9750e60..b1c3c1f020366 100755 --- a/src/plugins/chart_expressions/expression_tagcloud/kibana.json +++ b/src/plugins/chart_expressions/expression_tagcloud/kibana.json @@ -8,8 +8,8 @@ "requiredBundles": ["kibanaUtils"], "optionalPlugins": [], "owner": { - "name": "Kibana App", - "githubTeam": "kibana-app" + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" }, "description": "Expression Tagcloud plugin adds a `tagcloud` renderer and function to the expression plugin. The renderer will display the `Wordcloud` chart." } diff --git a/src/plugins/charts/common/index.ts b/src/plugins/charts/common/index.ts index 1a8b3eef93b92..ad3d2d11bbdfd 100644 --- a/src/plugins/charts/common/index.ts +++ b/src/plugins/charts/common/index.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +// TODO: https://github.com/elastic/kibana/issues/110891 +/* eslint-disable @kbn/eslint/no_export_all */ + export const COLOR_MAPPING_SETTING = 'visualization:colorMapping'; export * from './palette'; export * from './constants'; diff --git a/src/plugins/charts/kibana.json b/src/plugins/charts/kibana.json index 799173fed094f..86971d1018e0e 100644 --- a/src/plugins/charts/kibana.json +++ b/src/plugins/charts/kibana.json @@ -5,7 +5,7 @@ "ui": true, "requiredPlugins": ["expressions"], "owner": { - "name": "Kibana App", - "githubTeam": "kibana-app" + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" } } diff --git a/src/plugins/charts/public/index.ts b/src/plugins/charts/public/index.ts index 0eec6f14eff64..6674b98fce910 100644 --- a/src/plugins/charts/public/index.ts +++ b/src/plugins/charts/public/index.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +// TODO: https://github.com/elastic/kibana/issues/110891 +/* eslint-disable @kbn/eslint/no_export_all */ + import { ChartsPlugin } from './plugin'; export const plugin = () => new ChartsPlugin(); diff --git a/src/plugins/charts/public/services/active_cursor/use_active_cursor.test.ts b/src/plugins/charts/public/services/active_cursor/use_active_cursor.test.ts index efe5c9b49849f..50e7c995a1250 100644 --- a/src/plugins/charts/public/services/active_cursor/use_active_cursor.test.ts +++ b/src/plugins/charts/public/services/active_cursor/use_active_cursor.test.ts @@ -15,7 +15,8 @@ import type { ActiveCursorSyncOption, ActiveCursorPayload } from './types'; import type { Chart, PointerEvent } from '@elastic/charts'; import type { Datatable } from '../../../../expressions/public'; -describe('useActiveCursor', () => { +// FLAKY: https://github.com/elastic/kibana/issues/110038 +describe.skip('useActiveCursor', () => { let cursor: ActiveCursorPayload['cursor']; let dispatchExternalPointerEvent: jest.Mock; @@ -24,42 +25,47 @@ describe('useActiveCursor', () => { events: Array>, eventsTimeout = 1 ) => - new Promise(async (resolve) => { - const activeCursor = new ActiveCursor(); - let allEventsExecuted = false; - - activeCursor.setup(); + new Promise(async (resolve, reject) => { + try { + const activeCursor = new ActiveCursor(); + let allEventsExecuted = false; + activeCursor.setup(); + dispatchExternalPointerEvent.mockImplementation((pointerEvent) => { + if (allEventsExecuted) { + resolve(pointerEvent); + } + }); + renderHook(() => + useActiveCursor( + activeCursor, + { + current: { + dispatchExternalPointerEvent: dispatchExternalPointerEvent as ( + pointerEvent: PointerEvent + ) => void, + }, + } as RefObject, + { ...syncOption, debounce: syncOption.debounce ?? 1 } + ) + ); - dispatchExternalPointerEvent.mockImplementation((pointerEvent) => { - if (allEventsExecuted) { - resolve(pointerEvent); + for (const e of events) { + await new Promise((eventResolve) => + setTimeout(() => { + if (e === events[events.length - 1]) { + allEventsExecuted = true; + } + + activeCursor.activeCursor$!.next({ + cursor, + ...e, + }); + eventResolve(null); + }, eventsTimeout) + ); } - }); - - renderHook(() => - useActiveCursor( - activeCursor, - { - current: { - dispatchExternalPointerEvent: dispatchExternalPointerEvent as ( - pointerEvent: PointerEvent - ) => void, - }, - } as RefObject, - { ...syncOption, debounce: syncOption.debounce ?? 1 } - ) - ); - - for (const e of events) { - await new Promise((eventResolve) => - setTimeout(() => { - if (e === events[events.length - 1]) { - allEventsExecuted = true; - } - activeCursor.activeCursor$!.next({ cursor, ...e }); - eventResolve(null); - }, eventsTimeout) - ); + } catch (error) { + reject(error); } }); diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index a36788f949390..44285a4824211 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +// TODO: https://github.com/elastic/kibana/issues/109904 +/* eslint-disable @kbn/eslint/no_export_all */ + export * from './constants'; export * from './es_query'; export * from './index_patterns'; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 986e794c48488..595a88b412e9f 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +// TODO: https://github.com/elastic/kibana/issues/109904 +/* eslint-disable @kbn/eslint/no_export_all */ + import { PluginInitializerContext } from '../../../core/public'; import { ConfigSchema } from '../config'; @@ -62,7 +65,13 @@ export const indexPatterns = { flattenHitWrapper, }; -export { IndexPatternsContract, IndexPattern, IndexPatternField, TypeMeta } from './index_patterns'; +export { + IndexPatternsContract, + DataViewsContract, + IndexPattern, + IndexPatternField, + TypeMeta, +} from './index_patterns'; export { IIndexPattern, diff --git a/src/plugins/data/public/index_patterns/index.ts b/src/plugins/data/public/index_patterns/index.ts index 7229ca5750a38..d1a2b0f28f1d2 100644 --- a/src/plugins/data/public/index_patterns/index.ts +++ b/src/plugins/data/public/index_patterns/index.ts @@ -23,6 +23,9 @@ export { IndexPatternsContract, IndexPattern, IndexPatternsApiClient, + DataViewsService, + DataViewsContract, + DataView, } from './index_patterns'; export { UiSettingsPublicToCommon } from './ui_settings_wrapper'; export { SavedObjectsClientPublicToCommon } from './saved_objects_client_wrapper'; diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index b9b859fd96625..40882fa1134e9 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { DataPlugin, IndexPatternsContract } from '.'; +import { DataPlugin, DataViewsContract } from '.'; import { fieldFormatsServiceMock } from '../../field_formats/public/mocks'; import { searchServiceMock } from './search/mocks'; import { queryServiceMock } from './query/mocks'; @@ -38,6 +38,20 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { const queryStartMock = queryServiceMock.createStartContract(); + const dataViews = ({ + find: jest.fn((search) => [{ id: search, title: search }]), + createField: jest.fn(() => {}), + createFieldList: jest.fn(() => []), + ensureDefaultIndexPattern: jest.fn(), + make: () => ({ + fieldsFetcher: { + fetchForWildcard: jest.fn(), + }, + }), + get: jest.fn().mockReturnValue(Promise.resolve({})), + clearCache: jest.fn(), + } as unknown) as DataViewsContract; + return { actions: { createFiltersFromValueClickAction: jest.fn().mockResolvedValue(['yes']), @@ -51,19 +65,11 @@ const createStartContract = (): Start => { IndexPatternSelect: jest.fn(), SearchBar: jest.fn().mockReturnValue(null), }, - indexPatterns: ({ - find: jest.fn((search) => [{ id: search, title: search }]), - createField: jest.fn(() => {}), - createFieldList: jest.fn(() => []), - ensureDefaultIndexPattern: jest.fn(), - make: () => ({ - fieldsFetcher: { - fetchForWildcard: jest.fn(), - }, - }), - get: jest.fn().mockReturnValue(Promise.resolve({})), - clearCache: jest.fn(), - } as unknown) as IndexPatternsContract, + dataViews, + /** + * @deprecated Use dataViews service instead. All index pattern interfaces were renamed. + */ + indexPatterns: dataViews, nowProvider: createNowProviderMock(), }; }; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 67adcc7a1716d..a12bb50815982 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -197,6 +197,7 @@ export class DataPublicPlugin autocomplete: this.autocomplete.start(), fieldFormats, indexPatterns, + dataViews: indexPatterns, query, search, nowProvider: this.nowProvider, diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index 4b52ddfb68824..b31a4ab933ae2 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -17,7 +17,7 @@ import { AutocompleteSetup, AutocompleteStart } from './autocomplete'; import { createFiltersFromRangeSelectAction, createFiltersFromValueClickAction } from './actions'; import { ISearchSetup, ISearchStart } from './search'; import { QuerySetup, QueryStart } from './query'; -import { IndexPatternsContract } from './index_patterns'; +import { DataViewsContract } from './index_patterns'; import { IndexPatternSelectProps, StatefulSearchBarProps } from './ui'; import { UsageCollectionSetup, UsageCollectionStart } from '../../usage_collection/public'; import { Setup as InspectorSetup } from '../../inspector/public'; @@ -76,11 +76,17 @@ export interface DataPublicPluginStart { * {@link AutocompleteStart} */ autocomplete: AutocompleteStart; + /** + * data views service + * {@link DataViewsContract} + */ + dataViews: DataViewsContract; /** * index patterns service - * {@link IndexPatternsContract} + * {@link DataViewsContract} + * @deprecated Use dataViews service instead. All index pattern interfaces were renamed. */ - indexPatterns: IndexPatternsContract; + indexPatterns: DataViewsContract; /** * search service * {@link ISearchStart} diff --git a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap index 612ffdcf5029e..9cd0687a1074d 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap @@ -1403,7 +1403,7 @@ exports[`Inspector Data View component should render single table without select >
" +
markdown mock
My Canvas Workpad
" `; exports[`Canvas Shareable Workpad API Placed successfully with height specified 1`] = `"
"`; @@ -21,7 +21,7 @@ exports[`Canvas Shareable Workpad API Placed successfully with height specified
markdown mock
markdown mock
My Canvas Workpad
" +
markdown mock
My Canvas Workpad
" `; exports[`Canvas Shareable Workpad API Placed successfully with page specified 1`] = `"
"`; @@ -33,7 +33,7 @@ exports[`Canvas Shareable Workpad API Placed successfully with page specified 2`
markdown mock
markdown mock
My Canvas Workpad
" +
markdown mock
My Canvas Workpad
" `; exports[`Canvas Shareable Workpad API Placed successfully with width and height specified 1`] = `"
"`; @@ -45,7 +45,7 @@ exports[`Canvas Shareable Workpad API Placed successfully with width and height
markdown mock
markdown mock
My Canvas Workpad
" +
markdown mock
My Canvas Workpad
" `; exports[`Canvas Shareable Workpad API Placed successfully with width specified 1`] = `"
"`; @@ -57,5 +57,5 @@ exports[`Canvas Shareable Workpad API Placed successfully with width specified 2
markdown mock
markdown mock
My Canvas Workpad
" +
markdown mock
My Canvas Workpad
" `; diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot index c5b6d768c89d8..a5eefde192371 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot +++ b/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot @@ -1375,7 +1375,7 @@ exports[`Storyshots shareables/Canvas component 1`] = ` > - - - - - - - - - - - - - - - - - - - - -
-
- {{ ::'xpack.graph.sidebar.selectionsTitle' | i18n: { defaultMessage: 'Selections' } }} -
- -
- - - - - - - -
- -
-

- -
- - - - {{n.icon.code}} - - {{n.label}} - (+{{n.numChildren}}) - -
-
-
- - -
- -
- -
-
- - {{ ::'xpack.graph.sidebar.drillDownsTitle' | i18n: { defaultMessage: 'Drill-downs' } }} -
- -
-

- - -
-
- -
-
- - {{ ::'xpack.graph.sidebar.styleVerticesTitle' | i18n: { defaultMessage: 'Style selected vertices' } }} -
- -
- - -
-
- -
-
- - {{detail.latestNodeSelection.data.field}} {{detail.latestNodeSelection.data.term}} -
- - - - - -
-
- -
- -
-
-
-
-
- -
-
- - {{ ::'xpack.graph.sidebar.linkSummaryTitle' | i18n: { defaultMessage: 'Link summary' } }} -
-
- - - - {{mc.term1}} - {{mc.term2}} - - - - - - - - {{mc.v1}} -  ({{mc.overlap}})  - {{mc.v2}} -
-
- - - - - - - - - diff --git a/x-pack/plugins/graph/public/angular/templates/listing_ng_wrapper.html b/x-pack/plugins/graph/public/angular/templates/listing_ng_wrapper.html deleted file mode 100644 index b2363ffbaa641..0000000000000 --- a/x-pack/plugins/graph/public/angular/templates/listing_ng_wrapper.html +++ /dev/null @@ -1,13 +0,0 @@ - diff --git a/x-pack/plugins/graph/public/app.js b/x-pack/plugins/graph/public/app.js deleted file mode 100644 index 13661798cabe6..0000000000000 --- a/x-pack/plugins/graph/public/app.js +++ /dev/null @@ -1,646 +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 _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { Provider } from 'react-redux'; -import { isColorDark, hexToRgb } from '@elastic/eui'; - -import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; -import { showSaveModal } from '../../../../src/plugins/saved_objects/public'; - -import appTemplate from './angular/templates/index.html'; -import listingTemplate from './angular/templates/listing_ng_wrapper.html'; -import { getReadonlyBadge } from './badge'; - -import { GraphApp } from './components/app'; -import { VennDiagram } from './components/venn_diagram'; -import { Listing } from './components/listing'; -import { Settings } from './components/settings'; -import { GraphVisualization } from './components/graph_visualization'; - -import { createWorkspace } from './angular/graph_client_workspace.js'; -import { getEditUrl, getNewPath, getEditPath, setBreadcrumbs } from './services/url'; -import { createCachedIndexPatternProvider } from './services/index_pattern_cache'; -import { urlTemplateRegex } from './helpers/url_template'; -import { asAngularSyncedObservable } from './helpers/as_observable'; -import { colorChoices } from './helpers/style_choices'; -import { createGraphStore, datasourceSelector, hasFieldsSelector } from './state_management'; -import { formatHttpError } from './helpers/format_http_error'; -import { - findSavedWorkspace, - getSavedWorkspace, - deleteSavedWorkspace, -} from './helpers/saved_workspace_utils'; -import { InspectPanel } from './components/inspect_panel/inspect_panel'; - -export function initGraphApp(angularModule, deps) { - const { - chrome, - toastNotifications, - savedObjectsClient, - indexPatterns, - addBasePath, - getBasePath, - data, - capabilities, - coreStart, - storage, - canEditDrillDownUrls, - graphSavePolicy, - overlays, - savedObjects, - setHeaderActionMenu, - uiSettings, - } = deps; - - const app = angularModule; - - app.directive('vennDiagram', function (reactDirective) { - return reactDirective(VennDiagram); - }); - - app.directive('graphVisualization', function (reactDirective) { - return reactDirective(GraphVisualization); - }); - - app.directive('graphListing', function (reactDirective) { - return reactDirective(Listing, [ - ['coreStart', { watchDepth: 'reference' }], - ['createItem', { watchDepth: 'reference' }], - ['findItems', { watchDepth: 'reference' }], - ['deleteItems', { watchDepth: 'reference' }], - ['editItem', { watchDepth: 'reference' }], - ['getViewUrl', { watchDepth: 'reference' }], - ['listingLimit', { watchDepth: 'reference' }], - ['hideWriteControls', { watchDepth: 'reference' }], - ['capabilities', { watchDepth: 'reference' }], - ['initialFilter', { watchDepth: 'reference' }], - ['initialPageSize', { watchDepth: 'reference' }], - ]); - }); - - app.directive('graphApp', function (reactDirective) { - return reactDirective( - GraphApp, - [ - ['storage', { watchDepth: 'reference' }], - ['isInitialized', { watchDepth: 'reference' }], - ['currentIndexPattern', { watchDepth: 'reference' }], - ['indexPatternProvider', { watchDepth: 'reference' }], - ['isLoading', { watchDepth: 'reference' }], - ['onQuerySubmit', { watchDepth: 'reference' }], - ['initialQuery', { watchDepth: 'reference' }], - ['confirmWipeWorkspace', { watchDepth: 'reference' }], - ['coreStart', { watchDepth: 'reference' }], - ['noIndexPatterns', { watchDepth: 'reference' }], - ['reduxStore', { watchDepth: 'reference' }], - ['pluginDataStart', { watchDepth: 'reference' }], - ], - { restrict: 'A' } - ); - }); - - app.directive('graphVisualization', function (reactDirective) { - return reactDirective(GraphVisualization, undefined, { restrict: 'A' }); - }); - - app.directive('inspectPanel', function (reactDirective) { - return reactDirective( - InspectPanel, - [ - ['showInspect', { watchDepth: 'reference' }], - ['lastRequest', { watchDepth: 'reference' }], - ['lastResponse', { watchDepth: 'reference' }], - ['indexPattern', { watchDepth: 'reference' }], - ['uiSettings', { watchDepth: 'reference' }], - ], - { restrict: 'E' }, - { - uiSettings, - } - ); - }); - - app.config(function ($routeProvider) { - $routeProvider - .when('/home', { - template: listingTemplate, - badge: getReadonlyBadge, - controller: function ($location, $scope) { - $scope.listingLimit = savedObjects.settings.getListingLimit(); - $scope.initialPageSize = savedObjects.settings.getPerPage(); - $scope.create = () => { - $location.url(getNewPath()); - }; - $scope.find = (search) => { - return findSavedWorkspace( - { savedObjectsClient, basePath: coreStart.http.basePath }, - search, - $scope.listingLimit - ); - }; - $scope.editItem = (workspace) => { - $location.url(getEditPath(workspace)); - }; - $scope.getViewUrl = (workspace) => getEditUrl(addBasePath, workspace); - $scope.delete = (workspaces) => - deleteSavedWorkspace( - savedObjectsClient, - workspaces.map(({ id }) => id) - ); - $scope.capabilities = capabilities; - $scope.initialFilter = $location.search().filter || ''; - $scope.coreStart = coreStart; - setBreadcrumbs({ chrome }); - }, - }) - .when('/workspace/:id?', { - template: appTemplate, - badge: getReadonlyBadge, - resolve: { - savedWorkspace: function ($rootScope, $route, $location) { - return $route.current.params.id - ? getSavedWorkspace(savedObjectsClient, $route.current.params.id).catch(function (e) { - toastNotifications.addError(e, { - title: i18n.translate('xpack.graph.missingWorkspaceErrorMessage', { - defaultMessage: "Couldn't load graph with ID", - }), - }); - $rootScope.$eval(() => { - $location.path('/home'); - $location.replace(); - }); - // return promise that never returns to prevent the controller from loading - return new Promise(); - }) - : getSavedWorkspace(savedObjectsClient); - }, - indexPatterns: function () { - return savedObjectsClient - .find({ - type: 'index-pattern', - fields: ['title', 'type'], - perPage: 10000, - }) - .then((response) => response.savedObjects); - }, - GetIndexPatternProvider: function () { - return indexPatterns; - }, - }, - }) - .otherwise({ - redirectTo: '/home', - }); - }); - - //======== Controller for basic UI ================== - app.controller('graphuiPlugin', function ($scope, $route, $location) { - function handleError(err) { - const toastTitle = i18n.translate('xpack.graph.errorToastTitle', { - defaultMessage: 'Graph Error', - description: '"Graph" is a product name and should not be translated.', - }); - if (err instanceof Error) { - toastNotifications.addError(err, { - title: toastTitle, - }); - } else { - toastNotifications.addDanger({ - title: toastTitle, - text: String(err), - }); - } - } - - async function handleHttpError(error) { - toastNotifications.addDanger(formatHttpError(error)); - } - - // Replacement function for graphClientWorkspace's comms so - // that it works with Kibana. - function callNodeProxy(indexName, query, responseHandler) { - const request = { - body: JSON.stringify({ - index: indexName, - query: query, - }), - }; - $scope.loading = true; - return coreStart.http - .post('../api/graph/graphExplore', request) - .then(function (data) { - const response = data.resp; - if (response.timed_out) { - toastNotifications.addWarning( - i18n.translate('xpack.graph.exploreGraph.timedOutWarningText', { - defaultMessage: 'Exploration timed out', - }) - ); - } - responseHandler(response); - }) - .catch(handleHttpError) - .finally(() => { - $scope.loading = false; - $scope.$digest(); - }); - } - - //Helper function for the graphClientWorkspace to perform a query - const callSearchNodeProxy = function (indexName, query, responseHandler) { - const request = { - body: JSON.stringify({ - index: indexName, - body: query, - }), - }; - $scope.loading = true; - coreStart.http - .post('../api/graph/searchProxy', request) - .then(function (data) { - const response = data.resp; - responseHandler(response); - }) - .catch(handleHttpError) - .finally(() => { - $scope.loading = false; - $scope.$digest(); - }); - }; - - $scope.indexPatternProvider = createCachedIndexPatternProvider( - $route.current.locals.GetIndexPatternProvider.get - ); - - const store = createGraphStore({ - basePath: getBasePath(), - addBasePath, - indexPatternProvider: $scope.indexPatternProvider, - indexPatterns: $route.current.locals.indexPatterns, - createWorkspace: (indexPattern, exploreControls) => { - const options = { - indexName: indexPattern, - vertex_fields: [], - // Here we have the opportunity to look up labels for nodes... - nodeLabeller: function () { - // console.log(newNodes); - }, - changeHandler: function () { - //Allows DOM to update with graph layout changes. - $scope.$apply(); - }, - graphExploreProxy: callNodeProxy, - searchProxy: callSearchNodeProxy, - exploreControls, - }; - $scope.workspace = createWorkspace(options); - }, - setLiveResponseFields: (fields) => { - $scope.liveResponseFields = fields; - }, - setUrlTemplates: (urlTemplates) => { - $scope.urlTemplates = urlTemplates; - }, - getWorkspace: () => { - return $scope.workspace; - }, - getSavedWorkspace: () => { - return $route.current.locals.savedWorkspace; - }, - notifications: coreStart.notifications, - http: coreStart.http, - overlays: coreStart.overlays, - savedObjectsClient, - showSaveModal, - setWorkspaceInitialized: () => { - $scope.workspaceInitialized = true; - }, - savePolicy: graphSavePolicy, - changeUrl: (newUrl) => { - $scope.$evalAsync(() => { - $location.url(newUrl); - }); - }, - notifyAngular: () => { - $scope.$digest(); - }, - chrome, - I18nContext: coreStart.i18n.Context, - }); - - // register things on scope passed down to react components - $scope.pluginDataStart = data; - $scope.storage = storage; - $scope.coreStart = coreStart; - $scope.loading = false; - $scope.reduxStore = store; - $scope.savedWorkspace = $route.current.locals.savedWorkspace; - - // register things for legacy angular UI - const allSavingDisabled = graphSavePolicy === 'none'; - $scope.spymode = 'request'; - $scope.colors = colorChoices; - $scope.isColorDark = (color) => isColorDark(...hexToRgb(color)); - $scope.nodeClick = function (n, $event) { - //Selection logic - shift key+click helps selects multiple nodes - // Without the shift key we deselect all prior selections (perhaps not - // a great idea for touch devices with no concept of shift key) - if (!$event.shiftKey) { - const prevSelection = n.isSelected; - $scope.workspace.selectNone(); - n.isSelected = prevSelection; - } - - if ($scope.workspace.toggleNodeSelection(n)) { - $scope.selectSelected(n); - } else { - $scope.detail = null; - } - }; - - $scope.clickEdge = function (edge) { - $scope.workspace.getAllIntersections($scope.handleMergeCandidatesCallback, [ - edge.topSrc, - edge.topTarget, - ]); - }; - - $scope.submit = function (searchTerm) { - $scope.workspaceInitialized = true; - const numHops = 2; - if (searchTerm.startsWith('{')) { - try { - const query = JSON.parse(searchTerm); - if (query.vertices) { - // Is a graph explore request - $scope.workspace.callElasticsearch(query); - } else { - // Is a regular query DSL query - $scope.workspace.search(query, $scope.liveResponseFields, numHops); - } - } catch (err) { - handleError(err); - } - return; - } - $scope.workspace.simpleSearch(searchTerm, $scope.liveResponseFields, numHops); - }; - - $scope.selectSelected = function (node) { - $scope.detail = { - latestNodeSelection: node, - }; - return ($scope.selectedSelectedVertex = node); - }; - - $scope.isSelectedSelected = function (node) { - return $scope.selectedSelectedVertex === node; - }; - - $scope.openUrlTemplate = function (template) { - const url = template.url; - const newUrl = url.replace(urlTemplateRegex, template.encoder.encode($scope.workspace)); - window.open(newUrl, '_blank'); - }; - - $scope.aceLoaded = (editor) => { - editor.$blockScrolling = Infinity; - }; - - $scope.setDetail = function (data) { - $scope.detail = data; - }; - - function canWipeWorkspace(callback, text, options) { - if (!hasFieldsSelector(store.getState())) { - callback(); - return; - } - const confirmModalOptions = { - confirmButtonText: i18n.translate('xpack.graph.leaveWorkspace.confirmButtonLabel', { - defaultMessage: 'Leave anyway', - }), - title: i18n.translate('xpack.graph.leaveWorkspace.modalTitle', { - defaultMessage: 'Unsaved changes', - }), - 'data-test-subj': 'confirmModal', - ...options, - }; - - overlays - .openConfirm( - text || - i18n.translate('xpack.graph.leaveWorkspace.confirmText', { - defaultMessage: 'If you leave now, you will lose unsaved changes.', - }), - confirmModalOptions - ) - .then((isConfirmed) => { - if (isConfirmed) { - callback(); - } - }); - } - $scope.confirmWipeWorkspace = canWipeWorkspace; - - $scope.performMerge = function (parentId, childId) { - let found = true; - while (found) { - found = false; - for (const i in $scope.detail.mergeCandidates) { - if ($scope.detail.mergeCandidates.hasOwnProperty(i)) { - const mc = $scope.detail.mergeCandidates[i]; - if (mc.id1 === childId || mc.id2 === childId) { - $scope.detail.mergeCandidates.splice(i, 1); - found = true; - break; - } - } - } - } - $scope.workspace.mergeIds(parentId, childId); - $scope.detail = null; - }; - - $scope.handleMergeCandidatesCallback = function (termIntersects) { - const mergeCandidates = []; - termIntersects.forEach((ti) => { - mergeCandidates.push({ - id1: ti.id1, - id2: ti.id2, - term1: ti.term1, - term2: ti.term2, - v1: ti.v1, - v2: ti.v2, - overlap: ti.overlap, - }); - }); - $scope.detail = { mergeCandidates }; - }; - - // ===== Menubar configuration ========= - $scope.setHeaderActionMenu = setHeaderActionMenu; - $scope.topNavMenu = []; - $scope.topNavMenu.push({ - key: 'new', - label: i18n.translate('xpack.graph.topNavMenu.newWorkspaceLabel', { - defaultMessage: 'New', - }), - description: i18n.translate('xpack.graph.topNavMenu.newWorkspaceAriaLabel', { - defaultMessage: 'New Workspace', - }), - tooltip: i18n.translate('xpack.graph.topNavMenu.newWorkspaceTooltip', { - defaultMessage: 'Create a new workspace', - }), - run: function () { - canWipeWorkspace(function () { - $scope.$evalAsync(() => { - if ($location.url() === '/workspace/') { - $route.reload(); - } else { - $location.url('/workspace/'); - } - }); - }); - }, - testId: 'graphNewButton', - }); - - // if saving is disabled using uiCapabilities, we don't want to render the save - // button so it's consistent with all of the other applications - if (capabilities.save) { - // allSavingDisabled is based on the xpack.graph.savePolicy, we'll maintain this functionality - - $scope.topNavMenu.push({ - key: 'save', - label: i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledLabel', { - defaultMessage: 'Save', - }), - description: i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledAriaLabel', { - defaultMessage: 'Save workspace', - }), - tooltip: () => { - if (allSavingDisabled) { - return i18n.translate('xpack.graph.topNavMenu.saveWorkspace.disabledTooltip', { - defaultMessage: - 'No changes to saved workspaces are permitted by the current save policy', - }); - } else { - return i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledTooltip', { - defaultMessage: 'Save this workspace', - }); - } - }, - disableButton: function () { - return allSavingDisabled || !hasFieldsSelector(store.getState()); - }, - run: () => { - store.dispatch({ - type: 'x-pack/graph/SAVE_WORKSPACE', - payload: $route.current.locals.savedWorkspace, - }); - }, - testId: 'graphSaveButton', - }); - } - $scope.topNavMenu.push({ - key: 'inspect', - disableButton: function () { - return $scope.workspace === null; - }, - label: i18n.translate('xpack.graph.topNavMenu.inspectLabel', { - defaultMessage: 'Inspect', - }), - description: i18n.translate('xpack.graph.topNavMenu.inspectAriaLabel', { - defaultMessage: 'Inspect', - }), - run: () => { - $scope.$evalAsync(() => { - const curState = $scope.menus.showInspect; - $scope.closeMenus(); - $scope.menus.showInspect = !curState; - }); - }, - }); - - $scope.topNavMenu.push({ - key: 'settings', - disableButton: function () { - return datasourceSelector(store.getState()).type === 'none'; - }, - label: i18n.translate('xpack.graph.topNavMenu.settingsLabel', { - defaultMessage: 'Settings', - }), - description: i18n.translate('xpack.graph.topNavMenu.settingsAriaLabel', { - defaultMessage: 'Settings', - }), - run: () => { - const settingsObservable = asAngularSyncedObservable( - () => ({ - blocklistedNodes: $scope.workspace ? [...$scope.workspace.blocklistedNodes] : undefined, - unblocklistNode: $scope.workspace ? $scope.workspace.unblocklist : undefined, - canEditDrillDownUrls: canEditDrillDownUrls, - }), - $scope.$digest.bind($scope) - ); - coreStart.overlays.openFlyout( - toMountPoint( - - - - ), - { - size: 'm', - closeButtonAriaLabel: i18n.translate('xpack.graph.settings.closeLabel', { - defaultMessage: 'Close', - }), - 'data-test-subj': 'graphSettingsFlyout', - ownFocus: true, - className: 'gphSettingsFlyout', - maxWidth: 520, - } - ); - }, - }); - - // Allow URLs to include a user-defined text query - if ($route.current.params.query) { - $scope.initialQuery = $route.current.params.query; - const unbind = $scope.$watch('workspace', () => { - if (!$scope.workspace) { - return; - } - unbind(); - $scope.submit($route.current.params.query); - }); - } - - $scope.menus = { - showSettings: false, - }; - - $scope.closeMenus = () => { - _.forOwn($scope.menus, function (_, key) { - $scope.menus[key] = false; - }); - }; - - // Deal with situation of request to open saved workspace - if ($route.current.locals.savedWorkspace.id) { - store.dispatch({ - type: 'x-pack/graph/LOAD_WORKSPACE', - payload: $route.current.locals.savedWorkspace, - }); - } else { - $scope.noIndexPatterns = $route.current.locals.indexPatterns.length === 0; - } - }); - //End controller -} diff --git a/x-pack/plugins/graph/public/application.ts b/x-pack/plugins/graph/public/application.ts index 4d4b3c34de52b..7461a7b5fc172 100644 --- a/x-pack/plugins/graph/public/application.ts +++ b/x-pack/plugins/graph/public/application.ts @@ -5,20 +5,8 @@ * 2.0. */ -// inner angular imports -// these are necessary to bootstrap the local angular. -// They can stay even after NP cutover -import angular from 'angular'; -import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; +import { i18n } from '@kbn/i18n'; -import 'brace'; -import 'brace/mode/json'; - -// required for i18nIdDirective and `ngSanitize` angular module -import 'angular-sanitize'; -// required for ngRoute -import 'angular-route'; -// type imports import { ChromeStart, CoreStart, @@ -28,23 +16,21 @@ import { OverlayStart, AppMountParameters, IUiSettingsClient, + Capabilities, + ScopedHistory, } from 'kibana/public'; -// @ts-ignore -import { initGraphApp } from './app'; +import ReactDOM from 'react-dom'; import { DataPlugin, IndexPatternsContract } from '../../../../src/plugins/data/public'; import { LicensingPluginStart } from '../../licensing/public'; import { checkLicense } from '../common/check_license'; import { NavigationPublicPluginStart as NavigationStart } from '../../../../src/plugins/navigation/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; -import { - configureAppAngularModule, - createTopNavDirective, - createTopNavHelper, - KibanaLegacyStart, -} from '../../../../src/plugins/kibana_legacy/public'; +import { KibanaLegacyStart } from '../../../../src/plugins/kibana_legacy/public'; import './index.scss'; import { SavedObjectsStart } from '../../../../src/plugins/saved_objects/public'; +import { GraphSavePolicy } from './types'; +import { graphRouter } from './router'; /** * These are dependencies of the Graph app besides the base dependencies @@ -58,7 +44,7 @@ export interface GraphDependencies { coreStart: CoreStart; element: HTMLElement; appBasePath: string; - capabilities: Record>; + capabilities: Capabilities; navigation: NavigationStart; licensing: LicensingPluginStart; chrome: ChromeStart; @@ -70,22 +56,32 @@ export interface GraphDependencies { getBasePath: () => string; storage: Storage; canEditDrillDownUrls: boolean; - graphSavePolicy: string; + graphSavePolicy: GraphSavePolicy; overlays: OverlayStart; savedObjects: SavedObjectsStart; kibanaLegacy: KibanaLegacyStart; setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; uiSettings: IUiSettingsClient; + history: ScopedHistory; } -export const renderApp = ({ appBasePath, element, kibanaLegacy, ...deps }: GraphDependencies) => { +export type GraphServices = Omit; + +export const renderApp = ({ history, kibanaLegacy, element, ...deps }: GraphDependencies) => { + const { chrome, capabilities } = deps; kibanaLegacy.loadFontAwesome(); - const graphAngularModule = createLocalAngularModule(deps.navigation); - configureAppAngularModule( - graphAngularModule, - { core: deps.core, env: deps.pluginInitializerContext.env }, - true - ); + + if (!capabilities.graph.save) { + chrome.setBadge({ + text: i18n.translate('xpack.graph.badge.readOnly.text', { + defaultMessage: 'Read only', + }), + tooltip: i18n.translate('xpack.graph.badge.readOnly.tooltip', { + defaultMessage: 'Unable to save Graph workspaces', + }), + iconType: 'glasses', + }); + } const licenseSubscription = deps.licensing.license$.subscribe((license) => { const info = checkLicense(license); @@ -105,59 +101,19 @@ export const renderApp = ({ appBasePath, element, kibanaLegacy, ...deps }: Graph } }); - initGraphApp(graphAngularModule, deps); - const $injector = mountGraphApp(appBasePath, element); + // dispatch synthetic hash change event to update hash history objects + // this is necessary because hash updates triggered by using popState won't trigger this event naturally. + const unlistenParentHistory = history.listen(() => { + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + const app = graphRouter(deps); + ReactDOM.render(app, element); + element.setAttribute('class', 'gphAppWrapper'); + return () => { licenseSubscription.unsubscribe(); - $injector.get('$rootScope').$destroy(); + unlistenParentHistory(); + ReactDOM.unmountComponentAtNode(element); }; }; - -const mainTemplate = (basePath: string) => `
- -
-`; - -const moduleName = 'app/graph'; - -const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react', 'ui.bootstrap']; - -function mountGraphApp(appBasePath: string, element: HTMLElement) { - const mountpoint = document.createElement('div'); - mountpoint.setAttribute('class', 'gphAppWrapper'); - // eslint-disable-next-line no-unsanitized/property - mountpoint.innerHTML = mainTemplate(appBasePath); - // bootstrap angular into detached element and attach it later to - // make angular-within-angular possible - const $injector = angular.bootstrap(mountpoint, [moduleName]); - element.appendChild(mountpoint); - element.setAttribute('class', 'gphAppWrapper'); - return $injector; -} - -function createLocalAngularModule(navigation: NavigationStart) { - createLocalI18nModule(); - createLocalTopNavModule(navigation); - - const graphAngularModule = angular.module(moduleName, [ - ...thirdPartyAngularDependencies, - 'graphI18n', - 'graphTopNav', - ]); - return graphAngularModule; -} - -function createLocalTopNavModule(navigation: NavigationStart) { - angular - .module('graphTopNav', ['react']) - .directive('kbnTopNav', createTopNavDirective) - .directive('kbnTopNavHelper', createTopNavHelper(navigation.ui)); -} - -function createLocalI18nModule() { - angular - .module('graphI18n', []) - .provider('i18n', I18nProvider) - .filter('i18n', i18nFilter) - .directive('i18nId', i18nDirective); -} diff --git a/x-pack/plugins/graph/public/components/listing.tsx b/x-pack/plugins/graph/public/apps/listing_route.tsx similarity index 64% rename from x-pack/plugins/graph/public/components/listing.tsx rename to x-pack/plugins/graph/public/apps/listing_route.tsx index 53fdab4a02885..e7457f18005e6 100644 --- a/x-pack/plugins/graph/public/components/listing.tsx +++ b/x-pack/plugins/graph/public/apps/listing_route.tsx @@ -5,30 +5,72 @@ * 2.0. */ +import React, { Fragment, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; -import React, { Fragment } from 'react'; import { EuiEmptyPrompt, EuiLink, EuiButton } from '@elastic/eui'; - -import { CoreStart, ApplicationStart } from 'kibana/public'; +import { ApplicationStart } from 'kibana/public'; +import { useHistory, useLocation } from 'react-router-dom'; import { TableListView } from '../../../../../src/plugins/kibana_react/public'; +import { deleteSavedWorkspace, findSavedWorkspace } from '../helpers/saved_workspace_utils'; +import { getEditPath, getEditUrl, getNewPath, setBreadcrumbs } from '../services/url'; import { GraphWorkspaceSavedObject } from '../types'; +import { GraphServices } from '../application'; -export interface ListingProps { - coreStart: CoreStart; - createItem: () => void; - findItems: (query: string) => Promise<{ total: number; hits: GraphWorkspaceSavedObject[] }>; - deleteItems: (records: GraphWorkspaceSavedObject[]) => Promise; - editItem: (record: GraphWorkspaceSavedObject) => void; - getViewUrl: (record: GraphWorkspaceSavedObject) => string; - listingLimit: number; - hideWriteControls: boolean; - capabilities: { save: boolean; delete: boolean }; - initialFilter: string; - initialPageSize: number; +export interface ListingRouteProps { + deps: GraphServices; } -export function Listing(props: ListingProps) { +export function ListingRoute({ + deps: { chrome, savedObjects, savedObjectsClient, coreStart, capabilities, addBasePath }, +}: ListingRouteProps) { + const listingLimit = savedObjects.settings.getListingLimit(); + const initialPageSize = savedObjects.settings.getPerPage(); + const history = useHistory(); + const query = new URLSearchParams(useLocation().search); + const initialFilter = query.get('filter') || ''; + + useEffect(() => { + setBreadcrumbs({ chrome }); + }, [chrome]); + + const createItem = useCallback(() => { + history.push(getNewPath()); + }, [history]); + + const findItems = useCallback( + (search: string) => { + return findSavedWorkspace( + { savedObjectsClient, basePath: coreStart.http.basePath }, + search, + listingLimit + ); + }, + [coreStart.http.basePath, listingLimit, savedObjectsClient] + ); + + const editItem = useCallback( + (savedWorkspace: GraphWorkspaceSavedObject) => { + history.push(getEditPath(savedWorkspace)); + }, + [history] + ); + + const getViewUrl = useCallback( + (savedWorkspace: GraphWorkspaceSavedObject) => getEditUrl(addBasePath, savedWorkspace), + [addBasePath] + ); + + const deleteItems = useCallback( + async (savedWorkspaces: GraphWorkspaceSavedObject[]) => { + await deleteSavedWorkspace( + savedObjectsClient, + savedWorkspaces.map((cur) => cur.id!) + ); + }, + [savedObjectsClient] + ); + return ( { + /** + * It's temporary workaround, which should be removed after migration `workspace` to redux. + * Ref holds mutable `workspace` object. After each `workspace.methodName(...)` call + * (which might mutate `workspace` somehow), react state needs to be updated using + * `workspace.changeHandler()`. + */ + const workspaceRef = useRef(); + /** + * Providing `workspaceRef.current` to the hook dependencies or components itself + * will not leads to updates, therefore `renderCounter` is used to update react state. + */ + const [renderCounter, setRenderCounter] = useState(0); + const history = useHistory(); + const urlQuery = new URLSearchParams(useLocation().search).get('query'); + + const indexPatternProvider = useMemo( + () => createCachedIndexPatternProvider(getIndexPatternProvider.get), + [getIndexPatternProvider.get] + ); + + const { loading, callNodeProxy, callSearchNodeProxy, handleSearchQueryError } = useGraphLoader({ + toastNotifications, + coreStart, + }); + + const services = useMemo( + () => ({ + appName: 'graph', + storage, + data, + ...coreStart, + }), + [coreStart, data, storage] + ); + + const [store] = useState(() => + createGraphStore({ + basePath: getBasePath(), + addBasePath, + indexPatternProvider, + createWorkspace: (indexPattern, exploreControls) => { + const options = { + indexName: indexPattern, + vertex_fields: [], + // Here we have the opportunity to look up labels for nodes... + nodeLabeller() { + // console.log(newNodes); + }, + changeHandler: () => setRenderCounter((cur) => cur + 1), + graphExploreProxy: callNodeProxy, + searchProxy: callSearchNodeProxy, + exploreControls, + }; + const createdWorkspace = (workspaceRef.current = createWorkspace(options)); + return createdWorkspace; + }, + getWorkspace: () => workspaceRef.current, + notifications: coreStart.notifications, + http: coreStart.http, + overlays: coreStart.overlays, + savedObjectsClient, + showSaveModal, + savePolicy: graphSavePolicy, + changeUrl: (newUrl) => history.push(newUrl), + notifyReact: () => setRenderCounter((cur) => cur + 1), + chrome, + I18nContext: coreStart.i18n.Context, + handleSearchQueryError, + }) + ); + + const { savedWorkspace, indexPatterns } = useWorkspaceLoader({ + workspaceRef, + store, + savedObjectsClient, + toastNotifications, + }); + + if (!savedWorkspace || !indexPatterns) { + return null; + } + + return ( + + + + + + + + ); +}; diff --git a/x-pack/plugins/graph/public/badge.js b/x-pack/plugins/graph/public/badge.js deleted file mode 100644 index 128e30ee3f019..0000000000000 --- a/x-pack/plugins/graph/public/badge.js +++ /dev/null @@ -1,24 +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 { i18n } from '@kbn/i18n'; - -export function getReadonlyBadge(uiCapabilities) { - if (uiCapabilities.graph.save) { - return null; - } - - return { - text: i18n.translate('xpack.graph.badge.readOnly.text', { - defaultMessage: 'Read only', - }), - tooltip: i18n.translate('xpack.graph.badge.readOnly.tooltip', { - defaultMessage: 'Unable to save Graph workspaces', - }), - iconType: 'glasses', - }; -} diff --git a/x-pack/plugins/graph/public/angular/templates/_graph.scss b/x-pack/plugins/graph/public/components/_graph.scss similarity index 75% rename from x-pack/plugins/graph/public/angular/templates/_graph.scss rename to x-pack/plugins/graph/public/components/_graph.scss index 5c2f5d5f7a881..706389304067c 100644 --- a/x-pack/plugins/graph/public/angular/templates/_graph.scss +++ b/x-pack/plugins/graph/public/components/_graph.scss @@ -1,11 +1,3 @@ -@mixin gphSvgText() { - font-family: $euiFontFamily; - font-size: $euiSizeS; - line-height: $euiSizeM; - fill: $euiColorDarkShade; - color: $euiColorDarkShade; -} - /** * THE SVG Graph * 1. Calculated px values come from the open/closed state of the global nav sidebar diff --git a/x-pack/plugins/graph/public/components/_index.scss b/x-pack/plugins/graph/public/components/_index.scss index a06209e7e4d34..743c24c896426 100644 --- a/x-pack/plugins/graph/public/components/_index.scss +++ b/x-pack/plugins/graph/public/components/_index.scss @@ -7,3 +7,6 @@ @import './settings/index'; @import './legacy_icon/index'; @import './field_manager/index'; +@import './graph'; +@import './sidebar'; +@import './inspect'; diff --git a/x-pack/plugins/graph/public/angular/templates/_inspect.scss b/x-pack/plugins/graph/public/components/_inspect.scss similarity index 100% rename from x-pack/plugins/graph/public/angular/templates/_inspect.scss rename to x-pack/plugins/graph/public/components/_inspect.scss diff --git a/x-pack/plugins/graph/public/angular/templates/_sidebar.scss b/x-pack/plugins/graph/public/components/_sidebar.scss similarity index 82% rename from x-pack/plugins/graph/public/angular/templates/_sidebar.scss rename to x-pack/plugins/graph/public/components/_sidebar.scss index e784649b250fa..831032231fe8c 100644 --- a/x-pack/plugins/graph/public/angular/templates/_sidebar.scss +++ b/x-pack/plugins/graph/public/components/_sidebar.scss @@ -24,6 +24,10 @@ padding: $euiSizeXS; border-radius: $euiBorderRadius; margin-bottom: $euiSizeXS; + + & > span { + padding-right: $euiSizeXS; + } } .gphSidebar__panel { @@ -35,8 +39,9 @@ * Vertex Select */ -.gphVertexSelect__button { - margin: $euiSizeXS $euiSizeXS $euiSizeXS 0; +.vertexSelectionTypesBar { + margin-top: 0; + margin-bottom: 0; } /** @@ -68,15 +73,24 @@ background: $euiColorLightShade; } +/** + * Link summary + */ + +.gphDrillDownIconLinks { + margin-top: .5 * $euiSizeXS; + margin-bottom: .5 * $euiSizeXS; +} + /** * Link summary */ .gphLinkSummary__term--1 { - color:$euiColorDanger; + color: $euiColorDanger; } .gphLinkSummary__term--2 { - color:$euiColorPrimary; + color: $euiColorPrimary; } .gphLinkSummary__term--1-2 { color: mix($euiColorDanger, $euiColorPrimary); diff --git a/x-pack/plugins/graph/public/components/app.tsx b/x-pack/plugins/graph/public/components/app.tsx deleted file mode 100644 index fbe7f2d3ebe86..0000000000000 --- a/x-pack/plugins/graph/public/components/app.tsx +++ /dev/null @@ -1,76 +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 { EuiSpacer } from '@elastic/eui'; - -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { Provider } from 'react-redux'; -import React, { useState } from 'react'; -import { I18nProvider } from '@kbn/i18n/react'; -import { CoreStart } from 'kibana/public'; -import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { FieldManager } from './field_manager'; -import { SearchBarProps, SearchBar } from './search_bar'; -import { GraphStore } from '../state_management'; -import { GuidancePanel } from './guidance_panel'; -import { GraphTitle } from './graph_title'; - -import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; - -export interface GraphAppProps extends SearchBarProps { - coreStart: CoreStart; - // This is not named dataStart because of Angular treating data- prefix differently - pluginDataStart: DataPublicPluginStart; - storage: IStorageWrapper; - reduxStore: GraphStore; - isInitialized: boolean; - noIndexPatterns: boolean; -} - -export function GraphApp(props: GraphAppProps) { - const [pickerOpen, setPickerOpen] = useState(false); - const { - coreStart, - pluginDataStart, - storage, - reduxStore, - noIndexPatterns, - ...searchBarProps - } = props; - - return ( - - - - <> - {props.isInitialized && } -
- - - -
- {!props.isInitialized && ( - { - setPickerOpen(true); - }} - /> - )} - -
-
-
- ); -} diff --git a/x-pack/plugins/graph/public/components/control_panel/control_panel.tsx b/x-pack/plugins/graph/public/components/control_panel/control_panel.tsx new file mode 100644 index 0000000000000..2946bc8ad56f5 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/control_panel.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { connect } from 'react-redux'; +import { + ControlType, + TermIntersect, + UrlTemplate, + Workspace, + WorkspaceField, + WorkspaceNode, +} from '../../types'; +import { urlTemplateRegex } from '../../helpers/url_template'; +import { SelectionToolBar } from './selection_tool_bar'; +import { ControlPanelToolBar } from './control_panel_tool_bar'; +import { SelectStyle } from './select_style'; +import { SelectedNodeEditor } from './selected_node_editor'; +import { MergeCandidates } from './merge_candidates'; +import { DrillDowns } from './drill_downs'; +import { DrillDownIconLinks } from './drill_down_icon_links'; +import { GraphState, liveResponseFieldsSelector, templatesSelector } from '../../state_management'; +import { SelectedNodeItem } from './selected_node_item'; + +export interface TargetOptions { + toFields: WorkspaceField[]; +} + +interface ControlPanelProps { + renderCounter: number; + workspace: Workspace; + control: ControlType; + selectedNode?: WorkspaceNode; + colors: string[]; + mergeCandidates: TermIntersect[]; + onSetControl: (control: ControlType) => void; + selectSelected: (node: WorkspaceNode) => void; +} + +interface ControlPanelStateProps { + urlTemplates: UrlTemplate[]; + liveResponseFields: WorkspaceField[]; +} + +const ControlPanelComponent = ({ + workspace, + liveResponseFields, + urlTemplates, + control, + selectedNode, + colors, + mergeCandidates, + onSetControl, + selectSelected, +}: ControlPanelProps & ControlPanelStateProps) => { + const hasNodes = workspace.nodes.length === 0; + + const openUrlTemplate = (template: UrlTemplate) => { + const url = template.url; + const newUrl = url.replace(urlTemplateRegex, template.encoder.encode(workspace!)); + window.open(newUrl, '_blank'); + }; + + const onSelectedFieldClick = (node: WorkspaceNode) => { + selectSelected(node); + workspace.changeHandler(); + }; + + const onDeselectNode = (node: WorkspaceNode) => { + workspace.deselectNode(node); + workspace.changeHandler(); + onSetControl('none'); + }; + + return ( + + ); +}; + +export const ControlPanel = connect((state: GraphState) => ({ + urlTemplates: templatesSelector(state), + liveResponseFields: liveResponseFieldsSelector(state), +}))(ControlPanelComponent); diff --git a/x-pack/plugins/graph/public/components/control_panel/control_panel_tool_bar.tsx b/x-pack/plugins/graph/public/components/control_panel/control_panel_tool_bar.tsx new file mode 100644 index 0000000000000..37a9c003f7682 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/control_panel_tool_bar.tsx @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { ControlType, Workspace, WorkspaceField } from '../../types'; + +interface ControlPanelToolBarProps { + workspace: Workspace; + liveResponseFields: WorkspaceField[]; + onSetControl: (action: ControlType) => void; +} + +export const ControlPanelToolBar = ({ + workspace, + onSetControl, + liveResponseFields, +}: ControlPanelToolBarProps) => { + const haveNodes = workspace.nodes.length === 0; + + const undoButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.undoButtonTooltip', { + defaultMessage: 'Undo', + }); + const redoButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.redoButtonTooltip', { + defaultMessage: 'Redo', + }); + const expandButtonMsg = i18n.translate( + 'xpack.graph.sidebar.topMenu.expandSelectionButtonTooltip', + { + defaultMessage: 'Expand selection', + } + ); + const addLinksButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.addLinksButtonTooltip', { + defaultMessage: 'Add links between existing terms', + }); + const removeVerticesButtonMsg = i18n.translate( + 'xpack.graph.sidebar.topMenu.removeVerticesButtonTooltip', + { + defaultMessage: 'Remove vertices from workspace', + } + ); + const blocklistButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.blocklistButtonTooltip', { + defaultMessage: 'Block selection from appearing in workspace', + }); + const customStyleButtonMsg = i18n.translate( + 'xpack.graph.sidebar.topMenu.customStyleButtonTooltip', + { + defaultMessage: 'Custom style selected vertices', + } + ); + const drillDownButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.drillDownButtonTooltip', { + defaultMessage: 'Drill down', + }); + const runLayoutButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.runLayoutButtonTooltip', { + defaultMessage: 'Run layout', + }); + const pauseLayoutButtonMsg = i18n.translate( + 'xpack.graph.sidebar.topMenu.pauseLayoutButtonTooltip', + { + defaultMessage: 'Pause layout', + } + ); + + const onUndoClick = () => workspace.undo(); + const onRedoClick = () => workspace.redo(); + const onExpandButtonClick = () => { + onSetControl('none'); + workspace.expandSelecteds({ toFields: liveResponseFields }); + }; + const onAddLinksClick = () => workspace.fillInGraph(); + const onRemoveVerticesClick = () => { + onSetControl('none'); + workspace.deleteSelection(); + }; + const onBlockListClick = () => workspace.blocklistSelection(); + const onCustomStyleClick = () => onSetControl('style'); + const onDrillDownClick = () => onSetControl('drillDowns'); + const onRunLayoutClick = () => workspace.runLayout(); + const onPauseLayoutClick = () => { + workspace.stopLayout(); + workspace.changeHandler(); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {(workspace.nodes.length === 0 || workspace.force === null) && ( + + + + + + )} + + {workspace.force !== null && workspace.nodes.length > 0 && ( + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/graph/public/components/control_panel/drill_down_icon_links.tsx b/x-pack/plugins/graph/public/components/control_panel/drill_down_icon_links.tsx new file mode 100644 index 0000000000000..8d92d6ca04007 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/drill_down_icon_links.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import React from 'react'; +import { UrlTemplate } from '../../types'; + +interface UrlTemplateButtonsProps { + urlTemplates: UrlTemplate[]; + hasNodes: boolean; + openUrlTemplate: (template: UrlTemplate) => void; +} + +export const DrillDownIconLinks = ({ + hasNodes, + urlTemplates, + openUrlTemplate, +}: UrlTemplateButtonsProps) => { + const drillDownsWithIcons = urlTemplates.filter( + ({ icon }: UrlTemplate) => icon && icon.class !== '' + ); + + if (drillDownsWithIcons.length === 0) { + return null; + } + + const drillDowns = drillDownsWithIcons.map((cur) => { + const onUrlTemplateClick = () => openUrlTemplate(cur); + + return ( + + + + + + ); + }); + + return ( + + {drillDowns} + + ); +}; diff --git a/x-pack/plugins/graph/public/components/control_panel/drill_downs.tsx b/x-pack/plugins/graph/public/components/control_panel/drill_downs.tsx new file mode 100644 index 0000000000000..9d0dfdc7ba705 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/drill_downs.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { UrlTemplate } from '../../types'; + +interface DrillDownsProps { + urlTemplates: UrlTemplate[]; + openUrlTemplate: (template: UrlTemplate) => void; +} + +export const DrillDowns = ({ urlTemplates, openUrlTemplate }: DrillDownsProps) => { + return ( +
+
+ + {i18n.translate('xpack.graph.sidebar.drillDownsTitle', { + defaultMessage: 'Drill-downs', + })} +
+ +
+ {urlTemplates.length === 0 && ( +

+ {i18n.translate('xpack.graph.sidebar.drillDowns.noDrillDownsHelpText', { + defaultMessage: 'Configure drill-downs from the settings menu', + })} +

+ )} + +
    + {urlTemplates.map((urlTemplate) => { + const onOpenUrlTemplate = () => openUrlTemplate(urlTemplate); + + return ( +
  • + {urlTemplate.icon && ( + {urlTemplate.icon?.code} + )} + +
  • + ); + })} +
+
+
+ ); +}; diff --git a/x-pack/plugins/graph/public/components/control_panel/index.ts b/x-pack/plugins/graph/public/components/control_panel/index.ts new file mode 100644 index 0000000000000..7c3ab15baea2d --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './control_panel'; diff --git a/x-pack/plugins/graph/public/components/control_panel/merge_candidates.tsx b/x-pack/plugins/graph/public/components/control_panel/merge_candidates.tsx new file mode 100644 index 0000000000000..cc380993ef996 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/merge_candidates.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiToolTip } from '@elastic/eui'; +import { ControlType, TermIntersect, Workspace } from '../../types'; +import { VennDiagram } from '../venn_diagram'; + +interface MergeCandidatesProps { + workspace: Workspace; + mergeCandidates: TermIntersect[]; + onSetControl: (control: ControlType) => void; +} + +export const MergeCandidates = ({ + workspace, + mergeCandidates, + onSetControl, +}: MergeCandidatesProps) => { + const performMerge = (parentId: string, childId: string) => { + const tempMergeCandidates = [...mergeCandidates]; + let found = true; + while (found) { + found = false; + + for (let i = 0; i < tempMergeCandidates.length; i++) { + const term = tempMergeCandidates[i]; + if (term.id1 === childId || term.id2 === childId) { + tempMergeCandidates.splice(i, 1); + found = true; + break; + } + } + } + workspace.mergeIds(parentId, childId); + onSetControl('none'); + }; + + return ( +
+
+ + {i18n.translate('xpack.graph.sidebar.linkSummaryTitle', { + defaultMessage: 'Link summary', + })} +
+ {mergeCandidates.map((mc) => { + const mergeTerm1ToTerm2ButtonMsg = i18n.translate( + 'xpack.graph.sidebar.linkSummary.mergeTerm1ToTerm2ButtonTooltip', + { + defaultMessage: 'Merge {term1} into {term2}', + values: { term1: mc.term1, term2: mc.term2 }, + } + ); + const mergeTerm2ToTerm1ButtonMsg = i18n.translate( + 'xpack.graph.sidebar.linkSummary.mergeTerm2ToTerm1ButtonTooltip', + { + defaultMessage: 'Merge {term2} into {term1}', + values: { term1: mc.term1, term2: mc.term2 }, + } + ); + const leftTermCountMsg = i18n.translate( + 'xpack.graph.sidebar.linkSummary.leftTermCountTooltip', + { + defaultMessage: '{count} documents have term {term}', + values: { count: mc.v1, term: mc.term1 }, + } + ); + const bothTermsCountMsg = i18n.translate( + 'xpack.graph.sidebar.linkSummary.bothTermsCountTooltip', + { + defaultMessage: '{count} documents have both terms', + values: { count: mc.overlap }, + } + ); + const rightTermCountMsg = i18n.translate( + 'xpack.graph.sidebar.linkSummary.rightTermCountTooltip', + { + defaultMessage: '{count} documents have term {term}', + values: { count: mc.v2, term: mc.term2 }, + } + ); + + const onMergeTerm1ToTerm2Click = () => performMerge(mc.id2, mc.id1); + const onMergeTerm2ToTerm1Click = () => performMerge(mc.id1, mc.id2); + + return ( +
+ + + + + + {mc.term1} + {mc.term2} + + + + + + + + + + {mc.v1} + + +  ({mc.overlap})  + + + {mc.v2} + +
+ ); + })} +
+ ); +}; diff --git a/x-pack/plugins/graph/public/components/control_panel/select_style.tsx b/x-pack/plugins/graph/public/components/control_panel/select_style.tsx new file mode 100644 index 0000000000000..2dbefc7d24459 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/select_style.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { Workspace } from '../../types'; + +interface SelectStyleProps { + workspace: Workspace; + colors: string[]; +} + +export const SelectStyle = ({ colors, workspace }: SelectStyleProps) => { + return ( +
+
+ + {i18n.translate('xpack.graph.sidebar.styleVerticesTitle', { + defaultMessage: 'Style selected vertices', + })} +
+ +
+ {colors.map((c) => { + const onSelectColor = () => { + workspace.colorSelected(c); + workspace.changeHandler(); + }; + return ( +
+
+ ); +}; diff --git a/x-pack/plugins/graph/public/components/control_panel/selected_node_editor.tsx b/x-pack/plugins/graph/public/components/control_panel/selected_node_editor.tsx new file mode 100644 index 0000000000000..a0eed56fac672 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/selected_node_editor.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Workspace, WorkspaceNode } from '../../types'; + +interface SelectedNodeEditorProps { + workspace: Workspace; + selectedNode: WorkspaceNode; +} + +export const SelectedNodeEditor = ({ workspace, selectedNode }: SelectedNodeEditorProps) => { + const groupButtonMsg = i18n.translate('xpack.graph.sidebar.groupButtonTooltip', { + defaultMessage: 'group the currently selected items into {latestSelectionLabel}', + values: { latestSelectionLabel: selectedNode.label }, + }); + const ungroupButtonMsg = i18n.translate('xpack.graph.sidebar.ungroupButtonTooltip', { + defaultMessage: 'ungroup {latestSelectionLabel}', + values: { latestSelectionLabel: selectedNode.label }, + }); + + const onGroupButtonClick = () => { + workspace.groupSelections(selectedNode); + }; + const onClickUngroup = () => { + workspace.ungroup(selectedNode); + }; + const onChangeSelectedVertexLabel = (event: React.ChangeEvent) => { + selectedNode.label = event.target.value; + workspace.changeHandler(); + }; + + return ( +
+
+ {selectedNode.icon && } + {selectedNode.data.field} {selectedNode.data.term} +
+ + {(workspace.selectedNodes.length > 1 || + (workspace.selectedNodes.length > 0 && workspace.selectedNodes[0] !== selectedNode)) && ( + + + + )} + + {selectedNode.numChildren > 0 && ( + + + + )} + +
+
+ +
+ element && (element.value = selectedNode.label)} + type="text" + id="labelEdit" + className="form-control input-sm" + onChange={onChangeSelectedVertexLabel} + /> +
+ {i18n.translate('xpack.graph.sidebar.displayLabelHelpText', { + defaultMessage: 'Change the label for this vertex.', + })} +
+
+
+
+
+ ); +}; diff --git a/x-pack/plugins/graph/public/components/control_panel/selected_node_item.tsx b/x-pack/plugins/graph/public/components/control_panel/selected_node_item.tsx new file mode 100644 index 0000000000000..11df3b5d52086 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/selected_node_item.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { hexToRgb, isColorDark } from '@elastic/eui'; +import classNames from 'classnames'; +import React from 'react'; +import { WorkspaceNode } from '../../types'; + +const isHexColorDark = (color: string) => isColorDark(...hexToRgb(color)); + +interface SelectedNodeItemProps { + node: WorkspaceNode; + isHighlighted: boolean; + onDeselectNode: (node: WorkspaceNode) => void; + onSelectedFieldClick: (node: WorkspaceNode) => void; +} + +export const SelectedNodeItem = ({ + node, + isHighlighted, + onSelectedFieldClick, + onDeselectNode, +}: SelectedNodeItemProps) => { + const fieldClasses = classNames('gphSelectionList__field', { + ['gphSelectionList__field--selected']: isHighlighted, + }); + const fieldIconClasses = classNames('fa', 'gphNode__text', 'gphSelectionList__icon', { + ['gphNode__text--inverse']: isHexColorDark(node.color), + }); + + return ( + + ); +}; diff --git a/x-pack/plugins/graph/public/components/control_panel/selection_tool_bar.tsx b/x-pack/plugins/graph/public/components/control_panel/selection_tool_bar.tsx new file mode 100644 index 0000000000000..e2e9771a8e9ef --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/selection_tool_bar.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { ControlType, Workspace } from '../../types'; + +interface SelectionToolBarProps { + workspace: Workspace; + onSetControl: (data: ControlType) => void; +} + +export const SelectionToolBar = ({ workspace, onSetControl }: SelectionToolBarProps) => { + const haveNodes = workspace.nodes.length === 0; + + const selectAllButtonMsg = i18n.translate( + 'xpack.graph.sidebar.selections.selectAllButtonTooltip', + { + defaultMessage: 'Select all', + } + ); + const selectNoneButtonMsg = i18n.translate( + 'xpack.graph.sidebar.selections.selectNoneButtonTooltip', + { + defaultMessage: 'Select none', + } + ); + const invertSelectionButtonMsg = i18n.translate( + 'xpack.graph.sidebar.selections.invertSelectionButtonTooltip', + { + defaultMessage: 'Invert selection', + } + ); + const selectNeighboursButtonMsg = i18n.translate( + 'xpack.graph.sidebar.selections.selectNeighboursButtonTooltip', + { + defaultMessage: 'Select neighbours', + } + ); + + const onSelectAllClick = () => { + onSetControl('none'); + workspace.selectAll(); + workspace.changeHandler(); + }; + const onSelectNoneClick = () => { + onSetControl('none'); + workspace.selectNone(); + workspace.changeHandler(); + }; + const onInvertSelectionClick = () => { + onSetControl('none'); + workspace.selectInvert(); + workspace.changeHandler(); + }; + const onSelectNeighboursClick = () => { + onSetControl('none'); + workspace.selectNeighbours(); + workspace.changeHandler(); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/graph/public/components/graph_visualization/_graph_visualization.scss b/x-pack/plugins/graph/public/components/graph_visualization/_graph_visualization.scss index caef2b6987ddd..0853ab4114595 100644 --- a/x-pack/plugins/graph/public/components/graph_visualization/_graph_visualization.scss +++ b/x-pack/plugins/graph/public/components/graph_visualization/_graph_visualization.scss @@ -1,3 +1,11 @@ +@mixin gphSvgText() { + font-family: $euiFontFamily; + font-size: $euiSizeS; + line-height: $euiSizeM; + fill: $euiColorDarkShade; + color: $euiColorDarkShade; +} + .gphVisualization { flex: 1; display: flex; diff --git a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.test.tsx b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.test.tsx index f49b5bfd32da8..1ae556a79edcb 100644 --- a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.test.tsx +++ b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.test.tsx @@ -7,15 +7,13 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { - GraphVisualization, - GroupAwareWorkspaceNode, - GroupAwareWorkspaceEdge, -} from './graph_visualization'; +import { GraphVisualization } from './graph_visualization'; +import { Workspace, WorkspaceEdge, WorkspaceNode } from '../../types'; describe('graph_visualization', () => { - const nodes: GroupAwareWorkspaceNode[] = [ + const nodes: WorkspaceNode[] = [ { + id: '1', color: 'black', data: { field: 'A', @@ -37,6 +35,7 @@ describe('graph_visualization', () => { y: 5, }, { + id: '2', color: 'red', data: { field: 'B', @@ -58,6 +57,7 @@ describe('graph_visualization', () => { y: 9, }, { + id: '3', color: 'yellow', data: { field: 'C', @@ -79,7 +79,7 @@ describe('graph_visualization', () => { y: 9, }, ]; - const edges: GroupAwareWorkspaceEdge[] = [ + const edges: WorkspaceEdge[] = [ { isSelected: true, label: '', @@ -101,9 +101,32 @@ describe('graph_visualization', () => { width: 2.2, }, ]; + const workspace = ({ + nodes, + edges, + selectNone: () => {}, + changeHandler: jest.fn(), + toggleNodeSelection: jest.fn().mockImplementation((node: WorkspaceNode) => { + return !node.isSelected; + }), + getAllIntersections: jest.fn(), + } as unknown) as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render empty workspace without data', () => { - expect(shallow( {}} nodeClick={() => {}} />)) - .toMatchInlineSnapshot(` + expect( + shallow( + {}} + onSetControl={() => {}} + onSetMergeCandidates={() => {}} + /> + ) + ).toMatchInlineSnapshot(` { it('should render to svg elements', () => { expect( shallow( - {}} nodeClick={() => {}} nodes={nodes} edges={edges} /> + {}} + onSetControl={() => {}} + onSetMergeCandidates={() => {}} + /> ) ).toMatchSnapshot(); }); - it('should react to node click', () => { - const nodeClickSpy = jest.fn(); + it('should react to node selection', () => { + const selectSelectedMock = jest.fn(); + const instance = shallow( {}} - nodeClick={nodeClickSpy} - nodes={nodes} - edges={edges} + workspace={workspace} + selectSelected={selectSelectedMock} + onSetControl={() => {}} + onSetMergeCandidates={() => {}} /> ); + + instance.find('.gphNode').last().simulate('click', {}); + + expect(workspace.toggleNodeSelection).toHaveBeenCalledWith(nodes[2]); + expect(selectSelectedMock).toHaveBeenCalledWith(nodes[2]); + expect(workspace.changeHandler).toHaveBeenCalled(); + }); + + it('should react to node deselection', () => { + const onSetControlMock = jest.fn(); + const instance = shallow( + {}} + onSetControl={onSetControlMock} + onSetMergeCandidates={() => {}} + /> + ); + instance.find('.gphNode').first().simulate('click', {}); - expect(nodeClickSpy).toHaveBeenCalledWith(nodes[0], {}); + + expect(workspace.toggleNodeSelection).toHaveBeenCalledWith(nodes[0]); + expect(onSetControlMock).toHaveBeenCalledWith('none'); + expect(workspace.changeHandler).toHaveBeenCalled(); }); it('should react to edge click', () => { - const edgeClickSpy = jest.fn(); const instance = shallow( {}} - nodes={nodes} - edges={edges} + workspace={workspace} + selectSelected={() => {}} + onSetControl={() => {}} + onSetMergeCandidates={() => {}} /> ); + instance.find('.gphEdge').first().simulate('click'); - expect(edgeClickSpy).toHaveBeenCalledWith(edges[0]); + + expect(workspace.getAllIntersections).toHaveBeenCalled(); + expect(edges[0].topSrc).toEqual(workspace.getAllIntersections.mock.calls[0][1][0]); + expect(edges[0].topTarget).toEqual(workspace.getAllIntersections.mock.calls[0][1][1]); }); }); diff --git a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx index 9b8dc98b84f47..26359101a9a5b 100644 --- a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx +++ b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx @@ -9,31 +9,14 @@ import React, { useRef } from 'react'; import classNames from 'classnames'; import d3, { ZoomEvent } from 'd3'; import { isColorDark, hexToRgb } from '@elastic/eui'; -import { WorkspaceNode, WorkspaceEdge } from '../../types'; +import { Workspace, WorkspaceNode, TermIntersect, ControlType, WorkspaceEdge } from '../../types'; import { makeNodeId } from '../../services/persistence'; -/* - * The layouting algorithm sets a few extra properties on - * node objects to handle grouping. This will be moved to - * a separate data structure when the layouting is migrated - */ - -export interface GroupAwareWorkspaceNode extends WorkspaceNode { - kx: number; - ky: number; - numChildren: number; -} - -export interface GroupAwareWorkspaceEdge extends WorkspaceEdge { - topTarget: GroupAwareWorkspaceNode; - topSrc: GroupAwareWorkspaceNode; -} - export interface GraphVisualizationProps { - nodes?: GroupAwareWorkspaceNode[]; - edges?: GroupAwareWorkspaceEdge[]; - edgeClick: (edge: GroupAwareWorkspaceEdge) => void; - nodeClick: (node: GroupAwareWorkspaceNode, e: React.MouseEvent) => void; + workspace: Workspace; + onSetControl: (control: ControlType) => void; + selectSelected: (node: WorkspaceNode) => void; + onSetMergeCandidates: (terms: TermIntersect[]) => void; } function registerZooming(element: SVGSVGElement) { @@ -55,13 +38,39 @@ function registerZooming(element: SVGSVGElement) { } export function GraphVisualization({ - nodes, - edges, - edgeClick, - nodeClick, + workspace, + selectSelected, + onSetControl, + onSetMergeCandidates, }: GraphVisualizationProps) { const svgRoot = useRef(null); + const nodeClick = (n: WorkspaceNode, event: React.MouseEvent) => { + // Selection logic - shift key+click helps selects multiple nodes + // Without the shift key we deselect all prior selections (perhaps not + // a great idea for touch devices with no concept of shift key) + if (!event.shiftKey) { + const prevSelection = n.isSelected; + workspace.selectNone(); + n.isSelected = prevSelection; + } + if (workspace.toggleNodeSelection(n)) { + selectSelected(n); + } else { + onSetControl('none'); + } + workspace.changeHandler(); + }; + + const handleMergeCandidatesCallback = (termIntersects: TermIntersect[]) => { + const mergeCandidates: TermIntersect[] = [...termIntersects]; + onSetMergeCandidates(mergeCandidates); + onSetControl('mergeTerms'); + }; + + const edgeClick = (edge: WorkspaceEdge) => + workspace.getAllIntersections(handleMergeCandidatesCallback, [edge.topSrc, edge.topTarget]); + return ( - {edges && - edges.map((edge) => ( + {workspace.edges && + workspace.edges.map((edge) => ( ))} - {nodes && - nodes + {workspace.nodes && + workspace.nodes .filter((node) => !node.parent) .map((node) => ( {}; + +export const InspectPanel = ({ + showInspect, + lastRequest, + lastResponse, + indexPattern, +}: InspectPanelProps) => { + const [selectedTabId, setSelectedTabId] = useState('request'); + + const onRequestClick = () => setSelectedTabId('request'); + const onResponseClick = () => setSelectedTabId('response'); + + const editorContent = useMemo(() => (selectedTabId === 'request' ? lastRequest : lastResponse), [ + lastRequest, + lastResponse, + selectedTabId, + ]); + + if (showInspect) { + return ( +
+
+
+ +
+ +
+ + http://host:port/{indexPattern?.id}/_graph/explore + + + + + + + + + + +
+
+
+ ); + } + + return null; +}; diff --git a/x-pack/plugins/graph/public/components/inspect_panel/inspect_panel.tsx b/x-pack/plugins/graph/public/components/inspect_panel/inspect_panel.tsx deleted file mode 100644 index 2f29849bebcec..0000000000000 --- a/x-pack/plugins/graph/public/components/inspect_panel/inspect_panel.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useState } from 'react'; -import { EuiTab, EuiTabs, EuiText } from '@elastic/eui'; -import { monaco, XJsonLang } from '@kbn/monaco'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { IUiSettingsClient } from 'kibana/public'; -import { IndexPattern } from '../../../../../../src/plugins/data/public'; -import { - CodeEditor, - KibanaContextProvider, -} from '../../../../../../src/plugins/kibana_react/public'; - -interface InspectPanelProps { - showInspect?: boolean; - indexPattern?: IndexPattern; - uiSettings: IUiSettingsClient; - lastRequest?: string; - lastResponse?: string; -} - -const CODE_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = { - automaticLayout: true, - fontSize: 12, - lineNumbers: 'on', - minimap: { - enabled: false, - }, - overviewRulerBorder: false, - readOnly: true, - scrollbar: { - alwaysConsumeMouseWheel: false, - }, - scrollBeyondLastLine: false, - wordWrap: 'on', - wrappingIndent: 'indent', -}; - -const dummyCallback = () => {}; - -export const InspectPanel = ({ - showInspect, - lastRequest, - lastResponse, - indexPattern, - uiSettings, -}: InspectPanelProps) => { - const [selectedTabId, setSelectedTabId] = useState('request'); - - const onRequestClick = () => setSelectedTabId('request'); - const onResponseClick = () => setSelectedTabId('response'); - - const services = useMemo(() => ({ uiSettings }), [uiSettings]); - - const editorContent = useMemo(() => (selectedTabId === 'request' ? lastRequest : lastResponse), [ - selectedTabId, - lastRequest, - lastResponse, - ]); - - if (showInspect) { - return ( - -
-
-
- -
- -
- - http://host:port/{indexPattern?.id}/_graph/explore - - - - - - - - - - -
-
-
-
- ); - } - - return null; -}; diff --git a/x-pack/plugins/graph/public/components/search_bar.test.tsx b/x-pack/plugins/graph/public/components/search_bar.test.tsx index 690fdf832c373..1b76cde1a62fb 100644 --- a/x-pack/plugins/graph/public/components/search_bar.test.tsx +++ b/x-pack/plugins/graph/public/components/search_bar.test.tsx @@ -6,18 +6,18 @@ */ import { mountWithIntl } from '@kbn/test/jest'; -import { SearchBar, OuterSearchBarProps } from './search_bar'; -import React, { ReactElement } from 'react'; +import { SearchBar, SearchBarProps } from './search_bar'; +import React, { Component, ReactElement } from 'react'; import { CoreStart } from 'src/core/public'; import { act } from 'react-dom/test-utils'; import { IndexPattern, QueryStringInput } from '../../../../../src/plugins/data/public'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; -import { I18nProvider } from '@kbn/i18n/react'; +import { I18nProvider, InjectedIntl } from '@kbn/i18n/react'; import { openSourceModal } from '../services/source_modal'; -import { GraphStore, setDatasource } from '../state_management'; +import { GraphStore, setDatasource, submitSearchSaga } from '../state_management'; import { ReactWrapper } from 'enzyme'; import { createMockGraphStore } from '../state_management/mocks'; import { Provider } from 'react-redux'; @@ -26,7 +26,7 @@ jest.mock('../services/source_modal', () => ({ openSourceModal: jest.fn() })); const waitForIndexPatternFetch = () => new Promise((r) => setTimeout(r)); -function wrapSearchBarInContext(testProps: OuterSearchBarProps) { +function wrapSearchBarInContext(testProps: SearchBarProps) { const services = { uiSettings: { get: (key: string) => { @@ -67,21 +67,34 @@ function wrapSearchBarInContext(testProps: OuterSearchBarProps) { } describe('search_bar', () => { + let dispatchSpy: jest.Mock; + let instance: ReactWrapper< + SearchBarProps & { intl: InjectedIntl }, + Readonly<{}>, + Component<{}, {}, any> + >; + let store: GraphStore; const defaultProps = { isLoading: false, - onQuerySubmit: jest.fn(), indexPatternProvider: { get: jest.fn(() => Promise.resolve(({ fields: [] } as unknown) as IndexPattern)), }, confirmWipeWorkspace: (callback: () => void) => { callback(); }, + onIndexPatternChange: (indexPattern?: IndexPattern) => { + instance.setProps({ + ...defaultProps, + currentIndexPattern: indexPattern, + }); + }, }; - let instance: ReactWrapper; - let store: GraphStore; beforeEach(() => { - store = createMockGraphStore({}).store; + store = createMockGraphStore({ + sagas: [submitSearchSaga], + }).store; + store.dispatch( setDatasource({ type: 'indexpattern', @@ -89,14 +102,21 @@ describe('search_bar', () => { title: 'test-index', }) ); + + dispatchSpy = jest.fn(store.dispatch); + store.dispatch = dispatchSpy; }); async function mountSearchBar() { jest.clearAllMocks(); - const wrappedSearchBar = wrapSearchBarInContext({ ...defaultProps }); + const searchBarTestRoot = React.createElement((updatedProps: SearchBarProps) => ( + + {wrapSearchBarInContext({ ...defaultProps, ...updatedProps })} + + )); await act(async () => { - instance = mountWithIntl({wrappedSearchBar}); + instance = mountWithIntl(searchBarTestRoot); }); } @@ -119,7 +139,10 @@ describe('search_bar', () => { instance.find('form').simulate('submit', { preventDefault: () => {} }); }); - expect(defaultProps.onQuerySubmit).toHaveBeenCalledWith('testQuery'); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: 'x-pack/graph/workspace/SUBMIT_SEARCH', + payload: 'testQuery', + }); }); it('should translate kql query into JSON dsl', async () => { @@ -135,7 +158,7 @@ describe('search_bar', () => { instance.find('form').simulate('submit', { preventDefault: () => {} }); }); - const parsedQuery = JSON.parse(defaultProps.onQuerySubmit.mock.calls[0][0]); + const parsedQuery = JSON.parse(dispatchSpy.mock.calls[0][0].payload); expect(parsedQuery).toEqual({ bool: { should: [{ match: { test: 'abc' } }], minimum_should_match: 1 }, }); diff --git a/x-pack/plugins/graph/public/components/search_bar.tsx b/x-pack/plugins/graph/public/components/search_bar.tsx index fdf198c761957..fc7e3be3d0d37 100644 --- a/x-pack/plugins/graph/public/components/search_bar.tsx +++ b/x-pack/plugins/graph/public/components/search_bar.tsx @@ -17,6 +17,7 @@ import { datasourceSelector, requestDatasource, IndexpatternDatasource, + submitSearch, } from '../state_management'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; @@ -28,11 +29,11 @@ import { esKuery, } from '../../../../../src/plugins/data/public'; -export interface OuterSearchBarProps { +export interface SearchBarProps { isLoading: boolean; - initialQuery?: string; - onQuerySubmit: (query: string) => void; - + urlQuery: string | null; + currentIndexPattern?: IndexPattern; + onIndexPatternChange: (indexPattern?: IndexPattern) => void; confirmWipeWorkspace: ( onConfirm: () => void, text?: string, @@ -41,9 +42,10 @@ export interface OuterSearchBarProps { indexPatternProvider: IndexPatternProvider; } -export interface SearchBarProps extends OuterSearchBarProps { +export interface SearchBarStateProps { currentDatasource?: IndexpatternDatasource; onIndexPatternSelected: (indexPattern: IndexPatternSavedObject) => void; + submit: (searchTerm: string) => void; } function queryToString(query: Query, indexPattern: IndexPattern) { @@ -65,31 +67,34 @@ function queryToString(query: Query, indexPattern: IndexPattern) { return JSON.stringify(query.query); } -export function SearchBarComponent(props: SearchBarProps) { +export function SearchBarComponent(props: SearchBarStateProps & SearchBarProps) { const { - currentDatasource, - onQuerySubmit, isLoading, - onIndexPatternSelected, - initialQuery, + urlQuery, + currentIndexPattern, + currentDatasource, indexPatternProvider, + submit, + onIndexPatternSelected, confirmWipeWorkspace, + onIndexPatternChange, } = props; - const [query, setQuery] = useState({ language: 'kuery', query: initialQuery || '' }); - const [currentIndexPattern, setCurrentIndexPattern] = useState( - undefined - ); + const [query, setQuery] = useState({ language: 'kuery', query: urlQuery || '' }); + + useEffect(() => setQuery((prev) => ({ language: prev.language, query: urlQuery || '' })), [ + urlQuery, + ]); useEffect(() => { async function fetchPattern() { if (currentDatasource) { - setCurrentIndexPattern(await indexPatternProvider.get(currentDatasource.id)); + onIndexPatternChange(await indexPatternProvider.get(currentDatasource.id)); } else { - setCurrentIndexPattern(undefined); + onIndexPatternChange(undefined); } } fetchPattern(); - }, [currentDatasource, indexPatternProvider]); + }, [currentDatasource, indexPatternProvider, onIndexPatternChange]); const kibana = useKibana(); const { services, overlays } = kibana; @@ -101,7 +106,7 @@ export function SearchBarComponent(props: SearchBarProps) { onSubmit={(e) => { e.preventDefault(); if (!isLoading && currentIndexPattern) { - onQuerySubmit(queryToString(query, currentIndexPattern)); + submit(queryToString(query, currentIndexPattern)); } }} > @@ -196,5 +201,8 @@ export const SearchBar = connect( }) ); }, + submit: (searchTerm: string) => { + dispatch(submitSearch(searchTerm)); + }, }) )(SearchBarComponent); diff --git a/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx b/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx index 10ee306cd48a2..44ce606b0c1a9 100644 --- a/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx +++ b/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx @@ -8,8 +8,8 @@ import React, { useState, useEffect } from 'react'; import { EuiFormRow, EuiFieldNumber, EuiComboBox, EuiSwitch, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { SettingsProps } from './settings'; import { AdvancedSettings } from '../../types'; +import { SettingsStateProps } from './settings'; // Helper type to get all keys of an interface // that are of type number. @@ -26,9 +26,10 @@ export function AdvancedSettingsForm({ advancedSettings, updateSettings, allFields, -}: Pick) { +}: Pick) { // keep a local state during changes const [formState, updateFormState] = useState({ ...advancedSettings }); + // useEffect update localState only based on the main store useEffect(() => { updateFormState(advancedSettings); diff --git a/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx b/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx index 6f6b759f1ee1b..8954e812bdb88 100644 --- a/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx +++ b/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx @@ -17,14 +17,15 @@ import { EuiCallOut, } from '@elastic/eui'; -import { SettingsProps } from './settings'; +import { SettingsWorkspaceProps } from './settings'; import { LegacyIcon } from '../legacy_icon'; import { useListKeys } from './use_list_keys'; export function BlocklistForm({ blocklistedNodes, - unblocklistNode, -}: Pick) { + unblockNode, + unblockAll, +}: Pick) { const getListKey = useListKeys(blocklistedNodes || []); return ( <> @@ -46,7 +47,7 @@ export function BlocklistForm({ /> )} - {blocklistedNodes && unblocklistNode && blocklistedNodes.length > 0 && ( + {blocklistedNodes && blocklistedNodes.length > 0 && ( <> {blocklistedNodes.map((node) => ( @@ -63,9 +64,7 @@ export function BlocklistForm({ defaultMessage: 'Delete', }), color: 'danger', - onClick: () => { - unblocklistNode(node); - }, + onClick: () => unblockNode(node), }} /> ))} @@ -77,11 +76,7 @@ export function BlocklistForm({ iconType="trash" size="s" fill - onClick={() => { - blocklistedNodes.forEach((node) => { - unblocklistNode(node); - }); - }} + onClick={() => unblockAll()} > {i18n.translate('xpack.graph.settings.blocklist.clearButtonLabel', { defaultMessage: 'Delete all', diff --git a/x-pack/plugins/graph/public/components/settings/settings.test.tsx b/x-pack/plugins/graph/public/components/settings/settings.test.tsx index f0d506cf47556..060b1e93fbdc0 100644 --- a/x-pack/plugins/graph/public/components/settings/settings.test.tsx +++ b/x-pack/plugins/graph/public/components/settings/settings.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiTab, EuiListGroupItem, EuiButton, EuiAccordion, EuiFieldText } from '@elastic/eui'; import * as Rx from 'rxjs'; import { mountWithIntl } from '@kbn/test/jest'; -import { Settings, AngularProps } from './settings'; +import { Settings, SettingsWorkspaceProps } from './settings'; import { act } from '@testing-library/react'; import { ReactWrapper } from 'enzyme'; import { UrlTemplateForm } from './url_template_form'; @@ -46,7 +46,7 @@ describe('settings', () => { isDefault: false, }; - const angularProps: jest.Mocked = { + const workspaceProps: jest.Mocked = { blocklistedNodes: [ { x: 0, @@ -83,11 +83,12 @@ describe('settings', () => { }, }, ], - unblocklistNode: jest.fn(), + unblockNode: jest.fn(), + unblockAll: jest.fn(), canEditDrillDownUrls: true, }; - let subject: Rx.BehaviorSubject>; + let subject: Rx.BehaviorSubject>; let instance: ReactWrapper; beforeEach(() => { @@ -137,7 +138,7 @@ describe('settings', () => { ); dispatchSpy = jest.fn(store.dispatch); store.dispatch = dispatchSpy; - subject = new Rx.BehaviorSubject(angularProps); + subject = new Rx.BehaviorSubject(workspaceProps); instance = mountWithIntl( @@ -217,7 +218,7 @@ describe('settings', () => { it('should update on new data', () => { act(() => { subject.next({ - ...angularProps, + ...workspaceProps, blocklistedNodes: [ { x: 0, @@ -250,14 +251,13 @@ describe('settings', () => { it('should delete node', () => { instance.find(EuiListGroupItem).at(0).prop('extraAction')!.onClick!({} as any); - expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![0]); + expect(workspaceProps.unblockNode).toHaveBeenCalledWith(workspaceProps.blocklistedNodes![0]); }); it('should delete all nodes', () => { instance.find('[data-test-subj="graphUnblocklistAll"]').find(EuiButton).simulate('click'); - expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![0]); - expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![1]); + expect(workspaceProps.unblockAll).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/graph/public/components/settings/settings.tsx b/x-pack/plugins/graph/public/components/settings/settings.tsx index ab9cfdfe38072..d8f18add4f375 100644 --- a/x-pack/plugins/graph/public/components/settings/settings.tsx +++ b/x-pack/plugins/graph/public/components/settings/settings.tsx @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiFlyoutHeader, EuiTitle, EuiTabs, EuiFlyoutBody, EuiTab } from '@elastic/eui'; import * as Rx from 'rxjs'; import { connect } from 'react-redux'; @@ -14,7 +14,7 @@ import { bindActionCreators } from 'redux'; import { AdvancedSettingsForm } from './advanced_settings_form'; import { BlocklistForm } from './blocklist_form'; import { UrlTemplateList } from './url_template_list'; -import { WorkspaceNode, AdvancedSettings, UrlTemplate, WorkspaceField } from '../../types'; +import { AdvancedSettings, BlockListedNode, UrlTemplate, WorkspaceField } from '../../types'; import { GraphState, settingsSelector, @@ -47,16 +47,6 @@ const tabs = [ }, ]; -/** - * These props are wired in the angular scope and are passed in via observable - * to catch update outside updates - */ -export interface AngularProps { - blocklistedNodes: WorkspaceNode[]; - unblocklistNode: (node: WorkspaceNode) => void; - canEditDrillDownUrls: boolean; -} - export interface StateProps { advancedSettings: AdvancedSettings; urlTemplates: UrlTemplate[]; @@ -69,26 +59,43 @@ export interface DispatchProps { saveTemplate: (props: { index: number; template: UrlTemplate }) => void; } -interface AsObservable

{ +export interface SettingsWorkspaceProps { + blocklistedNodes: BlockListedNode[]; + unblockNode: (node: BlockListedNode) => void; + unblockAll: () => void; + canEditDrillDownUrls: boolean; +} + +export interface AsObservable

{ observable: Readonly>; } -export interface SettingsProps extends AngularProps, StateProps, DispatchProps {} +export interface SettingsStateProps extends StateProps, DispatchProps {} export function SettingsComponent({ observable, - ...props -}: AsObservable & StateProps & DispatchProps) { - const [angularProps, setAngularProps] = useState(undefined); + advancedSettings, + urlTemplates, + allFields, + saveTemplate: saveTemplateAction, + updateSettings: updateSettingsAction, + removeTemplate: removeTemplateAction, +}: AsObservable & SettingsStateProps) { + const [workspaceProps, setWorkspaceProps] = useState( + undefined + ); const [activeTab, setActiveTab] = useState(0); useEffect(() => { - observable.subscribe(setAngularProps); + observable.subscribe(setWorkspaceProps); }, [observable]); - if (!angularProps) return null; + if (!workspaceProps) { + return null; + } const ActiveTabContent = tabs[activeTab].component; + return ( <> @@ -97,7 +104,7 @@ export function SettingsComponent({ {tabs - .filter(({ id }) => id !== 'drillDowns' || angularProps.canEditDrillDownUrls) + .filter(({ id }) => id !== 'drillDowns' || workspaceProps.canEditDrillDownUrls) .map(({ title }, index) => ( - + ); } -export const Settings = connect, GraphState>( +export const Settings = connect< + StateProps, + DispatchProps, + AsObservable, + GraphState +>( (state: GraphState) => ({ advancedSettings: settingsSelector(state), urlTemplates: templatesSelector(state), diff --git a/x-pack/plugins/graph/public/components/settings/url_template_list.tsx b/x-pack/plugins/graph/public/components/settings/url_template_list.tsx index 24ce9dd267ad0..d18a9adb9bc0d 100644 --- a/x-pack/plugins/graph/public/components/settings/url_template_list.tsx +++ b/x-pack/plugins/graph/public/components/settings/url_template_list.tsx @@ -8,7 +8,7 @@ import React, { useState } from 'react'; import { EuiText, EuiSpacer, EuiTextAlign, EuiButton, htmlIdGenerator } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { SettingsProps } from './settings'; +import { SettingsStateProps } from './settings'; import { UrlTemplateForm } from './url_template_form'; import { useListKeys } from './use_list_keys'; @@ -18,7 +18,7 @@ export function UrlTemplateList({ removeTemplate, saveTemplate, urlTemplates, -}: Pick) { +}: Pick) { const [uncommittedForms, setUncommittedForms] = useState([]); const getListKey = useListKeys(urlTemplates); diff --git a/x-pack/plugins/graph/public/components/workspace_layout/index.ts b/x-pack/plugins/graph/public/components/workspace_layout/index.ts new file mode 100644 index 0000000000000..9f753a5bad576 --- /dev/null +++ b/x-pack/plugins/graph/public/components/workspace_layout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './workspace_layout'; diff --git a/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.tsx b/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.tsx new file mode 100644 index 0000000000000..70e5b82ec6526 --- /dev/null +++ b/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.tsx @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment, memo, useCallback, useRef, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSpacer } from '@elastic/eui'; +import { connect } from 'react-redux'; +import { SearchBar } from '../search_bar'; +import { + GraphState, + hasFieldsSelector, + workspaceInitializedSelector, +} from '../../state_management'; +import { FieldManager } from '../field_manager'; +import { IndexPattern } from '../../../../../../src/plugins/data/public'; +import { + ControlType, + IndexPatternProvider, + IndexPatternSavedObject, + TermIntersect, + WorkspaceNode, +} from '../../types'; +import { WorkspaceTopNavMenu } from './workspace_top_nav_menu'; +import { InspectPanel } from '../inspect_panel'; +import { GuidancePanel } from '../guidance_panel'; +import { GraphTitle } from '../graph_title'; +import { GraphWorkspaceSavedObject, Workspace } from '../../types'; +import { GraphServices } from '../../application'; +import { ControlPanel } from '../control_panel'; +import { GraphVisualization } from '../graph_visualization'; +import { colorChoices } from '../../helpers/style_choices'; + +/** + * Each component, which depends on `worksapce` + * should not be memoized, since it will not get updates. + * This behaviour should be changed after migrating `worksapce` to redux + */ +const FieldManagerMemoized = memo(FieldManager); +const GuidancePanelMemoized = memo(GuidancePanel); + +type WorkspaceLayoutProps = Pick< + GraphServices, + | 'setHeaderActionMenu' + | 'graphSavePolicy' + | 'navigation' + | 'capabilities' + | 'coreStart' + | 'canEditDrillDownUrls' + | 'overlays' +> & { + renderCounter: number; + workspace?: Workspace; + loading: boolean; + indexPatterns: IndexPatternSavedObject[]; + savedWorkspace: GraphWorkspaceSavedObject; + indexPatternProvider: IndexPatternProvider; + urlQuery: string | null; +}; + +interface WorkspaceLayoutStateProps { + workspaceInitialized: boolean; + hasFields: boolean; +} + +const WorkspaceLayoutComponent = ({ + renderCounter, + workspace, + loading, + savedWorkspace, + hasFields, + overlays, + workspaceInitialized, + indexPatterns, + indexPatternProvider, + capabilities, + coreStart, + graphSavePolicy, + navigation, + canEditDrillDownUrls, + urlQuery, + setHeaderActionMenu, +}: WorkspaceLayoutProps & WorkspaceLayoutStateProps) => { + const [currentIndexPattern, setCurrentIndexPattern] = useState(); + const [showInspect, setShowInspect] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); + const [mergeCandidates, setMergeCandidates] = useState([]); + const [control, setControl] = useState('none'); + const selectedNode = useRef(undefined); + const isInitialized = Boolean(workspaceInitialized || savedWorkspace.id); + + const selectSelected = useCallback((node: WorkspaceNode) => { + selectedNode.current = node; + setControl('editLabel'); + }, []); + + const onSetControl = useCallback((newControl: ControlType) => { + selectedNode.current = undefined; + setControl(newControl); + }, []); + + const onIndexPatternChange = useCallback( + (indexPattern?: IndexPattern) => setCurrentIndexPattern(indexPattern), + [] + ); + + const onOpenFieldPicker = useCallback(() => { + setPickerOpen(true); + }, []); + + const confirmWipeWorkspace = useCallback( + ( + onConfirm: () => void, + text?: string, + options?: { confirmButtonText: string; title: string } + ) => { + if (!hasFields) { + onConfirm(); + return; + } + const confirmModalOptions = { + confirmButtonText: i18n.translate('xpack.graph.leaveWorkspace.confirmButtonLabel', { + defaultMessage: 'Leave anyway', + }), + title: i18n.translate('xpack.graph.leaveWorkspace.modalTitle', { + defaultMessage: 'Unsaved changes', + }), + 'data-test-subj': 'confirmModal', + ...options, + }; + + overlays + .openConfirm( + text || + i18n.translate('xpack.graph.leaveWorkspace.confirmText', { + defaultMessage: 'If you leave now, you will lose unsaved changes.', + }), + confirmModalOptions + ) + .then((isConfirmed) => { + if (isConfirmed) { + onConfirm(); + } + }); + }, + [hasFields, overlays] + ); + + const onSetMergeCandidates = useCallback( + (terms: TermIntersect[]) => setMergeCandidates(terms), + [] + ); + + return ( + + + + + + {isInitialized && } +

+ + + +
+ {!isInitialized && ( +
+ +
+ )} + + {isInitialized && workspace && ( +
+
+ +
+ + +
+ )} + + ); +}; + +export const WorkspaceLayout = connect( + (state: GraphState) => ({ + workspaceInitialized: workspaceInitializedSelector(state), + hasFields: hasFieldsSelector(state), + }) +)(WorkspaceLayoutComponent); diff --git a/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx b/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx new file mode 100644 index 0000000000000..c5b10b9d92120 --- /dev/null +++ b/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { Provider, useStore } from 'react-redux'; +import { AppMountParameters, Capabilities, CoreStart } from 'kibana/public'; +import { useHistory, useLocation } from 'react-router-dom'; +import { NavigationPublicPluginStart as NavigationStart } from '../../../../../../src/plugins/navigation/public'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { datasourceSelector, hasFieldsSelector } from '../../state_management'; +import { GraphSavePolicy, GraphWorkspaceSavedObject, Workspace } from '../../types'; +import { AsObservable, Settings, SettingsWorkspaceProps } from '../settings'; +import { asSyncedObservable } from '../../helpers/as_observable'; + +interface WorkspaceTopNavMenuProps { + workspace: Workspace | undefined; + setShowInspect: React.Dispatch>; + confirmWipeWorkspace: ( + onConfirm: () => void, + text?: string, + options?: { confirmButtonText: string; title: string } + ) => void; + savedWorkspace: GraphWorkspaceSavedObject; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + graphSavePolicy: GraphSavePolicy; + navigation: NavigationStart; + capabilities: Capabilities; + coreStart: CoreStart; + canEditDrillDownUrls: boolean; + isInitialized: boolean; +} + +export const WorkspaceTopNavMenu = (props: WorkspaceTopNavMenuProps) => { + const store = useStore(); + const location = useLocation(); + const history = useHistory(); + + // register things for legacy angular UI + const allSavingDisabled = props.graphSavePolicy === 'none'; + + // ===== Menubar configuration ========= + const { TopNavMenu } = props.navigation.ui; + const topNavMenu = []; + topNavMenu.push({ + key: 'new', + label: i18n.translate('xpack.graph.topNavMenu.newWorkspaceLabel', { + defaultMessage: 'New', + }), + description: i18n.translate('xpack.graph.topNavMenu.newWorkspaceAriaLabel', { + defaultMessage: 'New Workspace', + }), + tooltip: i18n.translate('xpack.graph.topNavMenu.newWorkspaceTooltip', { + defaultMessage: 'Create a new workspace', + }), + disableButton() { + return !props.isInitialized; + }, + run() { + props.confirmWipeWorkspace(() => { + if (location.pathname === '/workspace/') { + history.go(0); + } else { + history.push('/workspace/'); + } + }); + }, + testId: 'graphNewButton', + }); + + // if saving is disabled using uiCapabilities, we don't want to render the save + // button so it's consistent with all of the other applications + if (props.capabilities.graph.save) { + // allSavingDisabled is based on the xpack.graph.savePolicy, we'll maintain this functionality + + topNavMenu.push({ + key: 'save', + label: i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledLabel', { + defaultMessage: 'Save', + }), + description: i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledAriaLabel', { + defaultMessage: 'Save workspace', + }), + tooltip: () => { + if (allSavingDisabled) { + return i18n.translate('xpack.graph.topNavMenu.saveWorkspace.disabledTooltip', { + defaultMessage: + 'No changes to saved workspaces are permitted by the current save policy', + }); + } else { + return i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledTooltip', { + defaultMessage: 'Save this workspace', + }); + } + }, + disableButton() { + return allSavingDisabled || !hasFieldsSelector(store.getState()); + }, + run: () => { + store.dispatch({ type: 'x-pack/graph/SAVE_WORKSPACE', payload: props.savedWorkspace }); + }, + testId: 'graphSaveButton', + }); + } + topNavMenu.push({ + key: 'inspect', + disableButton() { + return props.workspace === null; + }, + label: i18n.translate('xpack.graph.topNavMenu.inspectLabel', { + defaultMessage: 'Inspect', + }), + description: i18n.translate('xpack.graph.topNavMenu.inspectAriaLabel', { + defaultMessage: 'Inspect', + }), + run: () => { + props.setShowInspect((prevShowInspect) => !prevShowInspect); + }, + }); + + topNavMenu.push({ + key: 'settings', + disableButton() { + return datasourceSelector(store.getState()).current.type === 'none'; + }, + label: i18n.translate('xpack.graph.topNavMenu.settingsLabel', { + defaultMessage: 'Settings', + }), + description: i18n.translate('xpack.graph.topNavMenu.settingsAriaLabel', { + defaultMessage: 'Settings', + }), + run: () => { + // At this point workspace should be initialized, + // since settings button will be disabled only if workspace was set + const workspace = props.workspace as Workspace; + + const settingsObservable = (asSyncedObservable(() => ({ + blocklistedNodes: workspace.blocklistedNodes, + unblockNode: workspace.unblockNode, + unblockAll: workspace.unblockAll, + canEditDrillDownUrls: props.canEditDrillDownUrls, + })) as unknown) as AsObservable['observable']; + + props.coreStart.overlays.openFlyout( + toMountPoint( + + + + ), + { + size: 'm', + closeButtonAriaLabel: i18n.translate('xpack.graph.settings.closeLabel', { + defaultMessage: 'Close', + }), + 'data-test-subj': 'graphSettingsFlyout', + ownFocus: true, + className: 'gphSettingsFlyout', + maxWidth: 520, + } + ); + }, + }); + + return ( + + ); +}; diff --git a/x-pack/plugins/graph/public/helpers/as_observable.ts b/x-pack/plugins/graph/public/helpers/as_observable.ts index c1fa963641366..146161cceb46d 100644 --- a/x-pack/plugins/graph/public/helpers/as_observable.ts +++ b/x-pack/plugins/graph/public/helpers/as_observable.ts @@ -12,19 +12,20 @@ interface Props { } /** - * This is a helper to tie state updates that happen somewhere else back to an angular scope. + * This is a helper to tie state updates that happen somewhere else back to an react state. * It is roughly comparable to `reactDirective`, but does not have to be used from within a * template. * - * This is a temporary solution until the state management is moved outside of Angular. + * This is a temporary solution until the state of Workspace internals is moved outside + * of mutable object to the redux state (at least blocklistedNodes, canEditDrillDownUrls and + * unblocklist action in this case). * * @param collectProps Function that collects properties from the scope that should be passed - * into the observable. All functions passed along will be wrapped to cause an angular digest cycle - * and refresh the observable afterwards with a new call to `collectProps`. By doing so, angular - * can react to changes made outside of it and the results are passed back via the observable - * @param angularDigest The `$digest` function of the scope. + * into the observable. All functions passed along will be wrapped to cause a react render + * and refresh the observable afterwards with a new call to `collectProps`. By doing so, react + * will receive an update outside of it local state and the results are passed back via the observable. */ -export function asAngularSyncedObservable(collectProps: () => Props, angularDigest: () => void) { +export function asSyncedObservable(collectProps: () => Props) { const boundCollectProps = () => { const collectedProps = collectProps(); Object.keys(collectedProps).forEach((key) => { @@ -32,7 +33,6 @@ export function asAngularSyncedObservable(collectProps: () => Props, angularDige if (typeof currentValue === 'function') { collectedProps[key] = (...args: unknown[]) => { currentValue(...args); - angularDigest(); subject$.next(boundCollectProps()); }; } diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts index 1d8be0fe86b97..336708173d321 100644 --- a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts +++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts @@ -49,7 +49,7 @@ const defaultsProps = { const urlFor = (basePath: IBasePath, id: string) => basePath.prepend(`/app/graph#/workspace/${encodeURIComponent(id)}`); -function mapHits(hit: { id: string; attributes: Record }, url: string) { +function mapHits(hit: any, url: string): GraphWorkspaceSavedObject { const source = hit.attributes; source.id = hit.id; source.url = url; diff --git a/x-pack/plugins/graph/public/helpers/use_graph_loader.ts b/x-pack/plugins/graph/public/helpers/use_graph_loader.ts new file mode 100644 index 0000000000000..c133f6bf260cd --- /dev/null +++ b/x-pack/plugins/graph/public/helpers/use_graph_loader.ts @@ -0,0 +1,108 @@ +/* + * 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 { useCallback, useState } from 'react'; +import { ToastsStart } from 'kibana/public'; +import { IHttpFetchError, CoreStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { ExploreRequest, GraphExploreCallback, GraphSearchCallback, SearchRequest } from '../types'; +import { formatHttpError } from './format_http_error'; + +interface UseGraphLoaderProps { + toastNotifications: ToastsStart; + coreStart: CoreStart; +} + +export const useGraphLoader = ({ toastNotifications, coreStart }: UseGraphLoaderProps) => { + const [loading, setLoading] = useState(false); + + const handleHttpError = useCallback( + (error: IHttpFetchError) => { + toastNotifications.addDanger(formatHttpError(error)); + }, + [toastNotifications] + ); + + const handleSearchQueryError = useCallback( + (err: Error | string) => { + const toastTitle = i18n.translate('xpack.graph.errorToastTitle', { + defaultMessage: 'Graph Error', + description: '"Graph" is a product name and should not be translated.', + }); + if (err instanceof Error) { + toastNotifications.addError(err, { + title: toastTitle, + }); + } else { + toastNotifications.addDanger({ + title: toastTitle, + text: String(err), + }); + } + }, + [toastNotifications] + ); + + // Replacement function for graphClientWorkspace's comms so + // that it works with Kibana. + const callNodeProxy = useCallback( + (indexName: string, query: ExploreRequest, responseHandler: GraphExploreCallback) => { + const request = { + body: JSON.stringify({ + index: indexName, + query, + }), + }; + setLoading(true); + return coreStart.http + .post('../api/graph/graphExplore', request) + .then(function (data) { + const response = data.resp; + if (response.timed_out) { + toastNotifications.addWarning( + i18n.translate('xpack.graph.exploreGraph.timedOutWarningText', { + defaultMessage: 'Exploration timed out', + }) + ); + } + responseHandler(response); + }) + .catch(handleHttpError) + .finally(() => setLoading(false)); + }, + [coreStart.http, handleHttpError, toastNotifications] + ); + + // Helper function for the graphClientWorkspace to perform a query + const callSearchNodeProxy = useCallback( + (indexName: string, query: SearchRequest, responseHandler: GraphSearchCallback) => { + const request = { + body: JSON.stringify({ + index: indexName, + body: query, + }), + }; + setLoading(true); + coreStart.http + .post('../api/graph/searchProxy', request) + .then(function (data) { + const response = data.resp; + responseHandler(response); + }) + .catch(handleHttpError) + .finally(() => setLoading(false)); + }, + [coreStart.http, handleHttpError] + ); + + return { + loading, + callNodeProxy, + callSearchNodeProxy, + handleSearchQueryError, + }; +}; diff --git a/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts b/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts new file mode 100644 index 0000000000000..8b91546d52446 --- /dev/null +++ b/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract, ToastsStart } from 'kibana/public'; +import { useEffect, useState } from 'react'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { GraphStore } from '../state_management'; +import { GraphWorkspaceSavedObject, IndexPatternSavedObject, Workspace } from '../types'; +import { getSavedWorkspace } from './saved_workspace_utils'; + +interface UseWorkspaceLoaderProps { + store: GraphStore; + workspaceRef: React.MutableRefObject; + savedObjectsClient: SavedObjectsClientContract; + toastNotifications: ToastsStart; +} + +interface WorkspaceUrlParams { + id?: string; +} + +export const useWorkspaceLoader = ({ + workspaceRef, + store, + savedObjectsClient, + toastNotifications, +}: UseWorkspaceLoaderProps) => { + const [indexPatterns, setIndexPatterns] = useState(); + const [savedWorkspace, setSavedWorkspace] = useState(); + const history = useHistory(); + const location = useLocation(); + const { id } = useParams(); + + /** + * The following effect initializes workspace initially and reacts + * on changes in id parameter and URL query only. + */ + useEffect(() => { + const urlQuery = new URLSearchParams(location.search).get('query'); + + function loadWorkspace( + fetchedSavedWorkspace: GraphWorkspaceSavedObject, + fetchedIndexPatterns: IndexPatternSavedObject[] + ) { + store.dispatch({ + type: 'x-pack/graph/LOAD_WORKSPACE', + payload: { + savedWorkspace: fetchedSavedWorkspace, + indexPatterns: fetchedIndexPatterns, + urlQuery, + }, + }); + } + + function clearStore() { + store.dispatch({ type: 'x-pack/graph/RESET' }); + } + + async function fetchIndexPatterns() { + return await savedObjectsClient + .find<{ title: string }>({ + type: 'index-pattern', + fields: ['title', 'type'], + perPage: 10000, + }) + .then((response) => response.savedObjects); + } + + async function fetchSavedWorkspace() { + return (id + ? await getSavedWorkspace(savedObjectsClient, id).catch(function (e) { + toastNotifications.addError(e, { + title: i18n.translate('xpack.graph.missingWorkspaceErrorMessage', { + defaultMessage: "Couldn't load graph with ID", + }), + }); + history.replace('/home'); + // return promise that never returns to prevent the controller from loading + return new Promise(() => {}); + }) + : await getSavedWorkspace(savedObjectsClient)) as GraphWorkspaceSavedObject; + } + + async function initializeWorkspace() { + const fetchedIndexPatterns = await fetchIndexPatterns(); + const fetchedSavedWorkspace = await fetchSavedWorkspace(); + + /** + * Deal with situation of request to open saved workspace. Otherwise clean up store, + * when navigating to a new workspace from existing one. + */ + if (fetchedSavedWorkspace.id) { + loadWorkspace(fetchedSavedWorkspace, fetchedIndexPatterns); + } else if (workspaceRef.current) { + clearStore(); + } + + setIndexPatterns(fetchedIndexPatterns); + setSavedWorkspace(fetchedSavedWorkspace); + } + + initializeWorkspace(); + }, [ + id, + location, + store, + history, + savedObjectsClient, + setSavedWorkspace, + toastNotifications, + workspaceRef, + ]); + + return { savedWorkspace, indexPatterns }; +}; diff --git a/x-pack/plugins/graph/public/index.scss b/x-pack/plugins/graph/public/index.scss index f4e38de3e93a4..4062864dd41e0 100644 --- a/x-pack/plugins/graph/public/index.scss +++ b/x-pack/plugins/graph/public/index.scss @@ -10,5 +10,4 @@ @import './mixins'; @import './main'; -@import './angular/templates/index'; @import './components/index'; diff --git a/x-pack/plugins/graph/public/plugin.ts b/x-pack/plugins/graph/public/plugin.ts index 70671260ce5b9..1ff9afe505a3b 100644 --- a/x-pack/plugins/graph/public/plugin.ts +++ b/x-pack/plugins/graph/public/plugin.ts @@ -84,7 +84,6 @@ export class GraphPlugin updater$: this.appUpdater$, mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart] = await core.getStartServices(); - await pluginsStart.kibanaLegacy.loadAngularBootstrap(); coreStart.chrome.docTitle.change( i18n.translate('xpack.graph.pageTitle', { defaultMessage: 'Graph' }) ); @@ -104,7 +103,7 @@ export class GraphPlugin canEditDrillDownUrls: config.canEditDrillDownUrls, graphSavePolicy: config.savePolicy, storage: new Storage(window.localStorage), - capabilities: coreStart.application.capabilities.graph, + capabilities: coreStart.application.capabilities, chrome: coreStart.chrome, toastNotifications: coreStart.notifications.toasts, indexPatterns: pluginsStart.data!.indexPatterns, diff --git a/x-pack/plugins/graph/public/router.tsx b/x-pack/plugins/graph/public/router.tsx new file mode 100644 index 0000000000000..61a39bbbf63dd --- /dev/null +++ b/x-pack/plugins/graph/public/router.tsx @@ -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 React from 'react'; +import { createHashHistory } from 'history'; +import { Redirect, Route, Router, Switch } from 'react-router-dom'; +import { ListingRoute } from './apps/listing_route'; +import { GraphServices } from './application'; +import { WorkspaceRoute } from './apps/workspace_route'; + +export const graphRouter = (deps: GraphServices) => { + const history = createHashHistory(); + + return ( + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts b/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts index 443d8581c435d..31826c3b3a747 100644 --- a/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts +++ b/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts @@ -7,7 +7,7 @@ import { GraphWorkspaceSavedObject, IndexPatternSavedObject, Workspace } from '../../types'; import { migrateLegacyIndexPatternRef, savedWorkspaceToAppState, mapFields } from './deserialize'; -import { createWorkspace } from '../../angular/graph_client_workspace'; +import { createWorkspace } from '../../services/workspace/graph_client_workspace'; import { outlinkEncoders } from '../../helpers/outlink_encoders'; import { IndexPattern } from '../../../../../../src/plugins/data/public'; diff --git a/x-pack/plugins/graph/public/services/persistence/serialize.test.ts b/x-pack/plugins/graph/public/services/persistence/serialize.test.ts index 8213aac3fd62e..2466582bc7b25 100644 --- a/x-pack/plugins/graph/public/services/persistence/serialize.test.ts +++ b/x-pack/plugins/graph/public/services/persistence/serialize.test.ts @@ -146,7 +146,7 @@ describe('serialize', () => { target: appState.workspace.nodes[0], weight: 5, width: 5, - }); + } as WorkspaceEdge); // C <-> E appState.workspace.edges.push({ @@ -155,7 +155,7 @@ describe('serialize', () => { target: appState.workspace.nodes[4], weight: 5, width: 5, - }); + } as WorkspaceEdge); }); it('should serialize given workspace', () => { diff --git a/x-pack/plugins/graph/public/services/persistence/serialize.ts b/x-pack/plugins/graph/public/services/persistence/serialize.ts index 65392b69b5a6e..e1ec8db19a4c4 100644 --- a/x-pack/plugins/graph/public/services/persistence/serialize.ts +++ b/x-pack/plugins/graph/public/services/persistence/serialize.ts @@ -6,7 +6,6 @@ */ import { - SerializedNode, WorkspaceNode, WorkspaceEdge, SerializedEdge, @@ -17,13 +16,15 @@ import { SerializedWorkspaceState, Workspace, AdvancedSettings, + SerializedNode, + BlockListedNode, } from '../../types'; import { IndexpatternDatasource } from '../../state_management'; function serializeNode( - { data, scaledSize, parent, x, y, label, color }: WorkspaceNode, + { data, scaledSize, parent, x, y, label, color }: BlockListedNode, allNodes: WorkspaceNode[] = [] -): SerializedNode { +) { return { x, y, diff --git a/x-pack/plugins/graph/public/services/save_modal.tsx b/x-pack/plugins/graph/public/services/save_modal.tsx index eff98ebeded47..f1603ed790d3a 100644 --- a/x-pack/plugins/graph/public/services/save_modal.tsx +++ b/x-pack/plugins/graph/public/services/save_modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { ReactElement } from 'react'; import { I18nStart, OverlayStart, SavedObjectsClientContract } from 'src/core/public'; import { SaveResult } from 'src/plugins/saved_objects/public'; import { GraphWorkspaceSavedObject, GraphSavePolicy } from '../types'; @@ -39,7 +39,7 @@ export function openSaveModal({ hasData: boolean; workspace: GraphWorkspaceSavedObject; saveWorkspace: SaveWorkspaceHandler; - showSaveModal: (el: React.ReactNode, I18nContext: I18nStart['Context']) => void; + showSaveModal: (el: ReactElement, I18nContext: I18nStart['Context']) => void; I18nContext: I18nStart['Context']; services: SaveWorkspaceServices; }) { diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.d.ts b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.d.ts similarity index 100% rename from x-pack/plugins/graph/public/angular/graph_client_workspace.d.ts rename to x-pack/plugins/graph/public/services/workspace/graph_client_workspace.d.ts diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.js b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js similarity index 99% rename from x-pack/plugins/graph/public/angular/graph_client_workspace.js rename to x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js index 07e4dfc2e874a..c849a25cb19bb 100644 --- a/x-pack/plugins/graph/public/angular/graph_client_workspace.js +++ b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js @@ -631,10 +631,14 @@ function GraphWorkspace(options) { self.runLayout(); }; - this.unblocklist = function (node) { + this.unblockNode = function (node) { self.arrRemove(self.blocklistedNodes, node); }; + this.unblockAll = function () { + self.arrRemoveAll(self.blocklistedNodes, self.blocklistedNodes); + }; + this.blocklistSelection = function () { const selection = self.getAllSelectedNodes(); const danglingEdges = []; diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.test.js similarity index 100% rename from x-pack/plugins/graph/public/angular/graph_client_workspace.test.js rename to x-pack/plugins/graph/public/services/workspace/graph_client_workspace.test.js diff --git a/x-pack/plugins/graph/public/state_management/advanced_settings.ts b/x-pack/plugins/graph/public/state_management/advanced_settings.ts index 82f1358dd4164..68b9e002766e3 100644 --- a/x-pack/plugins/graph/public/state_management/advanced_settings.ts +++ b/x-pack/plugins/graph/public/state_management/advanced_settings.ts @@ -43,14 +43,14 @@ export const settingsSelector = (state: GraphState) => state.advancedSettings; * * Won't be necessary once the workspace is moved to redux */ -export const syncSettingsSaga = ({ getWorkspace, notifyAngular }: GraphStoreDependencies) => { +export const syncSettingsSaga = ({ getWorkspace, notifyReact }: GraphStoreDependencies) => { function* syncSettings(action: Action): IterableIterator { const workspace = getWorkspace(); if (!workspace) { return; } workspace.options.exploreControls = action.payload; - notifyAngular(); + notifyReact(); } return function* () { diff --git a/x-pack/plugins/graph/public/state_management/datasource.sagas.ts b/x-pack/plugins/graph/public/state_management/datasource.sagas.ts index b185af28c3481..9bfc7b3da0f91 100644 --- a/x-pack/plugins/graph/public/state_management/datasource.sagas.ts +++ b/x-pack/plugins/graph/public/state_management/datasource.sagas.ts @@ -30,7 +30,7 @@ export const datasourceSaga = ({ indexPatternProvider, notifications, createWorkspace, - notifyAngular, + notifyReact, }: GraphStoreDependencies) => { function* fetchFields(action: Action) { try { @@ -39,7 +39,7 @@ export const datasourceSaga = ({ yield put(datasourceLoaded()); const advancedSettings = settingsSelector(yield select()); createWorkspace(indexPattern.title, advancedSettings); - notifyAngular(); + notifyReact(); } catch (e) { // in case of errors, reset the datasource and show notification yield put(setDatasource({ type: 'none' })); diff --git a/x-pack/plugins/graph/public/state_management/fields.ts b/x-pack/plugins/graph/public/state_management/fields.ts index 051f5328091e1..3a117fa6fe50a 100644 --- a/x-pack/plugins/graph/public/state_management/fields.ts +++ b/x-pack/plugins/graph/public/state_management/fields.ts @@ -69,9 +69,9 @@ export const hasFieldsSelector = createSelector( * * Won't be necessary once the workspace is moved to redux */ -export const updateSaveButtonSaga = ({ notifyAngular }: GraphStoreDependencies) => { +export const updateSaveButtonSaga = ({ notifyReact }: GraphStoreDependencies) => { function* notify(): IterableIterator { - notifyAngular(); + notifyReact(); } return function* () { yield takeLatest(matchesOne(selectField, deselectField), notify); @@ -84,7 +84,7 @@ export const updateSaveButtonSaga = ({ notifyAngular }: GraphStoreDependencies) * * Won't be necessary once the workspace is moved to redux */ -export const syncFieldsSaga = ({ getWorkspace, setLiveResponseFields }: GraphStoreDependencies) => { +export const syncFieldsSaga = ({ getWorkspace }: GraphStoreDependencies) => { function* syncFields() { const workspace = getWorkspace(); if (!workspace) { @@ -93,7 +93,6 @@ export const syncFieldsSaga = ({ getWorkspace, setLiveResponseFields }: GraphSto const currentState = yield select(); workspace.options.vertex_fields = selectedFieldsSelector(currentState); - setLiveResponseFields(liveResponseFieldsSelector(currentState)); } return function* () { yield takeEvery( @@ -109,7 +108,7 @@ export const syncFieldsSaga = ({ getWorkspace, setLiveResponseFields }: GraphSto * * Won't be necessary once the workspace is moved to redux */ -export const syncNodeStyleSaga = ({ getWorkspace, notifyAngular }: GraphStoreDependencies) => { +export const syncNodeStyleSaga = ({ getWorkspace, notifyReact }: GraphStoreDependencies) => { function* syncNodeStyle(action: Action>) { const workspace = getWorkspace(); if (!workspace) { @@ -132,7 +131,7 @@ export const syncNodeStyleSaga = ({ getWorkspace, notifyAngular }: GraphStoreDep } }); } - notifyAngular(); + notifyReact(); const selectedFields = selectedFieldsSelector(yield select()); workspace.options.vertex_fields = selectedFields; diff --git a/x-pack/plugins/graph/public/state_management/legacy.test.ts b/x-pack/plugins/graph/public/state_management/legacy.test.ts index 1dbad39a918a5..5a05efdc478fc 100644 --- a/x-pack/plugins/graph/public/state_management/legacy.test.ts +++ b/x-pack/plugins/graph/public/state_management/legacy.test.ts @@ -77,13 +77,12 @@ describe('legacy sync sagas', () => { it('syncs templates with workspace', () => { env.store.dispatch(loadTemplates([])); - expect(env.mockedDeps.setUrlTemplates).toHaveBeenCalledWith([]); - expect(env.mockedDeps.notifyAngular).toHaveBeenCalled(); + expect(env.mockedDeps.notifyReact).toHaveBeenCalled(); }); it('notifies angular when fields are selected', () => { env.store.dispatch(selectField('field1')); - expect(env.mockedDeps.notifyAngular).toHaveBeenCalled(); + expect(env.mockedDeps.notifyReact).toHaveBeenCalled(); }); it('syncs field list with workspace', () => { @@ -99,9 +98,6 @@ describe('legacy sync sagas', () => { const workspace = env.mockedDeps.getWorkspace()!; expect(workspace.options.vertex_fields![0].name).toEqual('field1'); expect(workspace.options.vertex_fields![0].hopSize).toEqual(22); - expect(env.mockedDeps.setLiveResponseFields).toHaveBeenCalledWith([ - expect.objectContaining({ hopSize: 22 }), - ]); }); it('syncs styles with nodes', () => { diff --git a/x-pack/plugins/graph/public/state_management/mocks.ts b/x-pack/plugins/graph/public/state_management/mocks.ts index 74d980753a09a..189875d04b015 100644 --- a/x-pack/plugins/graph/public/state_management/mocks.ts +++ b/x-pack/plugins/graph/public/state_management/mocks.ts @@ -15,7 +15,7 @@ import createSagaMiddleware from 'redux-saga'; import { createStore, applyMiddleware, AnyAction } from 'redux'; import { ChromeStart } from 'kibana/public'; import { GraphStoreDependencies, createRootReducer, GraphStore, GraphState } from './store'; -import { Workspace, GraphWorkspaceSavedObject, IndexPatternSavedObject } from '../types'; +import { Workspace } from '../types'; import { IndexPattern } from '../../../../../src/plugins/data/public'; export interface MockedGraphEnvironment { @@ -48,11 +48,8 @@ export function createMockGraphStore({ blocklistedNodes: [], } as unknown) as Workspace; - const savedWorkspace = ({ - save: jest.fn(), - } as unknown) as GraphWorkspaceSavedObject; - const mockedDeps: jest.Mocked = { + basePath: '', addBasePath: jest.fn((url: string) => url), changeUrl: jest.fn(), chrome: ({ @@ -60,15 +57,11 @@ export function createMockGraphStore({ } as unknown) as ChromeStart, createWorkspace: jest.fn(), getWorkspace: jest.fn(() => workspaceMock), - getSavedWorkspace: jest.fn(() => savedWorkspace), indexPatternProvider: { get: jest.fn(() => Promise.resolve(({ id: '123', title: 'test-pattern' } as unknown) as IndexPattern) ), }, - indexPatterns: [ - ({ id: '123', attributes: { title: 'test-pattern' } } as unknown) as IndexPatternSavedObject, - ], I18nContext: jest .fn() .mockImplementation(({ children }: { children: React.ReactNode }) => children), @@ -79,12 +72,9 @@ export function createMockGraphStore({ }, } as unknown) as NotificationsStart, http: {} as HttpStart, - notifyAngular: jest.fn(), + notifyReact: jest.fn(), savePolicy: 'configAndData', showSaveModal: jest.fn(), - setLiveResponseFields: jest.fn(), - setUrlTemplates: jest.fn(), - setWorkspaceInitialized: jest.fn(), overlays: ({ openModal: jest.fn(), } as unknown) as OverlayStart, @@ -92,6 +82,7 @@ export function createMockGraphStore({ find: jest.fn(), get: jest.fn(), } as unknown) as SavedObjectsClientContract, + handleSearchQueryError: jest.fn(), ...mockedDepsOverwrites, }; const sagaMiddleware = createSagaMiddleware(); diff --git a/x-pack/plugins/graph/public/state_management/persistence.test.ts b/x-pack/plugins/graph/public/state_management/persistence.test.ts index b0932c92c2d1e..dc59869fafd4c 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.test.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.test.ts @@ -6,8 +6,14 @@ */ import { createMockGraphStore, MockedGraphEnvironment } from './mocks'; -import { loadSavedWorkspace, loadingSaga, saveWorkspace, savingSaga } from './persistence'; -import { GraphWorkspaceSavedObject, UrlTemplate, AdvancedSettings, WorkspaceField } from '../types'; +import { + loadSavedWorkspace, + loadingSaga, + saveWorkspace, + savingSaga, + LoadSavedWorkspacePayload, +} from './persistence'; +import { UrlTemplate, AdvancedSettings, WorkspaceField, GraphWorkspaceSavedObject } from '../types'; import { IndexpatternDatasource, datasourceSelector } from './datasource'; import { fieldsSelector } from './fields'; import { metaDataSelector, updateMetaData } from './meta_data'; @@ -55,7 +61,9 @@ describe('persistence sagas', () => { }); it('should deserialize saved object and populate state', async () => { env.store.dispatch( - loadSavedWorkspace({ title: 'my workspace' } as GraphWorkspaceSavedObject) + loadSavedWorkspace({ + savedWorkspace: { title: 'my workspace' }, + } as LoadSavedWorkspacePayload) ); await waitForPromise(); const resultingState = env.store.getState(); @@ -70,7 +78,7 @@ describe('persistence sagas', () => { it('should warn with a toast and abort if index pattern is not found', async () => { (migrateLegacyIndexPatternRef as jest.Mock).mockReturnValueOnce({ success: false }); - env.store.dispatch(loadSavedWorkspace({} as GraphWorkspaceSavedObject)); + env.store.dispatch(loadSavedWorkspace({ savedWorkspace: {} } as LoadSavedWorkspacePayload)); await waitForPromise(); expect(env.mockedDeps.notifications.toasts.addDanger).toHaveBeenCalled(); const resultingState = env.store.getState(); @@ -96,11 +104,10 @@ describe('persistence sagas', () => { savePolicy: 'configAndDataWithConsent', }, }); - env.mockedDeps.getSavedWorkspace().id = '123'; }); it('should serialize saved object and save after confirmation', async () => { - env.store.dispatch(saveWorkspace()); + env.store.dispatch(saveWorkspace({ id: '123' } as GraphWorkspaceSavedObject)); (openSaveModal as jest.Mock).mock.calls[0][0].saveWorkspace({}, true); expect(appStateToSavedWorkspace).toHaveBeenCalled(); await waitForPromise(); @@ -112,7 +119,7 @@ describe('persistence sagas', () => { }); it('should not save data if user does not give consent in the modal', async () => { - env.store.dispatch(saveWorkspace()); + env.store.dispatch(saveWorkspace({} as GraphWorkspaceSavedObject)); (openSaveModal as jest.Mock).mock.calls[0][0].saveWorkspace({}, false); // serialize function is called with `canSaveData` set to false expect(appStateToSavedWorkspace).toHaveBeenCalledWith( @@ -123,9 +130,8 @@ describe('persistence sagas', () => { }); it('should not change url if it was just updating existing workspace', async () => { - env.mockedDeps.getSavedWorkspace().id = '123'; env.store.dispatch(updateMetaData({ savedObjectId: '123' })); - env.store.dispatch(saveWorkspace()); + env.store.dispatch(saveWorkspace({} as GraphWorkspaceSavedObject)); await waitForPromise(); expect(env.mockedDeps.changeUrl).not.toHaveBeenCalled(); }); diff --git a/x-pack/plugins/graph/public/state_management/persistence.ts b/x-pack/plugins/graph/public/state_management/persistence.ts index f815474fa6e51..6a99eaddb32e3 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.ts @@ -8,8 +8,8 @@ import actionCreatorFactory, { Action } from 'typescript-fsa'; import { i18n } from '@kbn/i18n'; import { takeLatest, call, put, select, cps } from 'redux-saga/effects'; -import { GraphWorkspaceSavedObject, Workspace } from '../types'; -import { GraphStoreDependencies, GraphState } from '.'; +import { GraphWorkspaceSavedObject, IndexPatternSavedObject, Workspace } from '../types'; +import { GraphStoreDependencies, GraphState, submitSearch } from '.'; import { datasourceSelector } from './datasource'; import { setDatasource, IndexpatternDatasource } from './datasource'; import { loadFields, selectedFieldsSelector } from './fields'; @@ -26,10 +26,17 @@ import { openSaveModal, SaveWorkspaceHandler } from '../services/save_modal'; import { getEditPath } from '../services/url'; import { saveSavedWorkspace } from '../helpers/saved_workspace_utils'; +export interface LoadSavedWorkspacePayload { + indexPatterns: IndexPatternSavedObject[]; + savedWorkspace: GraphWorkspaceSavedObject; + urlQuery: string | null; +} + const actionCreator = actionCreatorFactory('x-pack/graph'); -export const loadSavedWorkspace = actionCreator('LOAD_WORKSPACE'); -export const saveWorkspace = actionCreator('SAVE_WORKSPACE'); +export const loadSavedWorkspace = actionCreator('LOAD_WORKSPACE'); +export const saveWorkspace = actionCreator('SAVE_WORKSPACE'); +export const fillWorkspace = actionCreator('FILL_WORKSPACE'); /** * Saga handling loading of a saved workspace. @@ -39,14 +46,12 @@ export const saveWorkspace = actionCreator('SAVE_WORKSPACE'); */ export const loadingSaga = ({ createWorkspace, - getWorkspace, - indexPatterns, notifications, indexPatternProvider, }: GraphStoreDependencies) => { - function* deserializeWorkspace(action: Action) { - const workspacePayload = action.payload; - const migrationStatus = migrateLegacyIndexPatternRef(workspacePayload, indexPatterns); + function* deserializeWorkspace(action: Action) { + const { indexPatterns, savedWorkspace, urlQuery } = action.payload; + const migrationStatus = migrateLegacyIndexPatternRef(savedWorkspace, indexPatterns); if (!migrationStatus.success) { notifications.toasts.addDanger( i18n.translate('xpack.graph.loadWorkspace.missingIndexPatternErrorMessage', { @@ -59,25 +64,24 @@ export const loadingSaga = ({ return; } - const selectedIndexPatternId = lookupIndexPatternId(workspacePayload); + const selectedIndexPatternId = lookupIndexPatternId(savedWorkspace); const indexPattern = yield call(indexPatternProvider.get, selectedIndexPatternId); const initialSettings = settingsSelector(yield select()); - createWorkspace(indexPattern.title, initialSettings); + const createdWorkspace = createWorkspace(indexPattern.title, initialSettings); const { urlTemplates, advancedSettings, allFields } = savedWorkspaceToAppState( - workspacePayload, + savedWorkspace, indexPattern, - // workspace won't be null because it's created in the same call stack - getWorkspace()! + createdWorkspace ); // put everything in the store yield put( updateMetaData({ - title: workspacePayload.title, - description: workspacePayload.description, - savedObjectId: workspacePayload.id, + title: savedWorkspace.title, + description: savedWorkspace.description, + savedObjectId: savedWorkspace.id, }) ); yield put( @@ -91,7 +95,11 @@ export const loadingSaga = ({ yield put(updateSettings(advancedSettings)); yield put(loadTemplates(urlTemplates)); - getWorkspace()!.runLayout(); + if (urlQuery) { + yield put(submitSearch(urlQuery)); + } + + createdWorkspace.runLayout(); } return function* () { @@ -105,8 +113,8 @@ export const loadingSaga = ({ * It will serialize everything and save it using the saved objects client */ export const savingSaga = (deps: GraphStoreDependencies) => { - function* persistWorkspace() { - const savedWorkspace = deps.getSavedWorkspace(); + function* persistWorkspace(action: Action) { + const savedWorkspace = action.payload; const state: GraphState = yield select(); const workspace = deps.getWorkspace(); const selectedDatasource = datasourceSelector(state).current; diff --git a/x-pack/plugins/graph/public/state_management/store.ts b/x-pack/plugins/graph/public/state_management/store.ts index 400736f7534b6..ba9bff98b0ca9 100644 --- a/x-pack/plugins/graph/public/state_management/store.ts +++ b/x-pack/plugins/graph/public/state_management/store.ts @@ -9,6 +9,7 @@ import createSagaMiddleware, { SagaMiddleware } from 'redux-saga'; import { combineReducers, createStore, Store, AnyAction, Dispatch, applyMiddleware } from 'redux'; import { ChromeStart, I18nStart, OverlayStart, SavedObjectsClientContract } from 'kibana/public'; import { CoreStart } from 'src/core/public'; +import { ReactElement } from 'react'; import { fieldsReducer, FieldsState, @@ -24,19 +25,10 @@ import { } from './advanced_settings'; import { DatasourceState, datasourceReducer } from './datasource'; import { datasourceSaga } from './datasource.sagas'; -import { - IndexPatternProvider, - Workspace, - IndexPatternSavedObject, - GraphSavePolicy, - GraphWorkspaceSavedObject, - AdvancedSettings, - WorkspaceField, - UrlTemplate, -} from '../types'; +import { IndexPatternProvider, Workspace, GraphSavePolicy, AdvancedSettings } from '../types'; import { loadingSaga, savingSaga } from './persistence'; import { metaDataReducer, MetaDataState, syncBreadcrumbSaga } from './meta_data'; -import { fillWorkspaceSaga } from './workspace'; +import { fillWorkspaceSaga, submitSearchSaga, workspaceReducer, WorkspaceState } from './workspace'; export interface GraphState { fields: FieldsState; @@ -44,28 +36,26 @@ export interface GraphState { advancedSettings: AdvancedSettingsState; datasource: DatasourceState; metaData: MetaDataState; + workspace: WorkspaceState; } export interface GraphStoreDependencies { addBasePath: (url: string) => string; indexPatternProvider: IndexPatternProvider; - indexPatterns: IndexPatternSavedObject[]; - createWorkspace: (index: string, advancedSettings: AdvancedSettings) => void; - getWorkspace: () => Workspace | null; - getSavedWorkspace: () => GraphWorkspaceSavedObject; + createWorkspace: (index: string, advancedSettings: AdvancedSettings) => Workspace; + getWorkspace: () => Workspace | undefined; notifications: CoreStart['notifications']; http: CoreStart['http']; overlays: OverlayStart; savedObjectsClient: SavedObjectsClientContract; - showSaveModal: (el: React.ReactNode, I18nContext: I18nStart['Context']) => void; + showSaveModal: (el: ReactElement, I18nContext: I18nStart['Context']) => void; savePolicy: GraphSavePolicy; changeUrl: (newUrl: string) => void; - notifyAngular: () => void; - setLiveResponseFields: (fields: WorkspaceField[]) => void; - setUrlTemplates: (templates: UrlTemplate[]) => void; - setWorkspaceInitialized: () => void; + notifyReact: () => void; chrome: ChromeStart; I18nContext: I18nStart['Context']; + basePath: string; + handleSearchQueryError: (err: Error | string) => void; } export function createRootReducer(addBasePath: (url: string) => string) { @@ -75,6 +65,7 @@ export function createRootReducer(addBasePath: (url: string) => string) { advancedSettings: advancedSettingsReducer, datasource: datasourceReducer, metaData: metaDataReducer, + workspace: workspaceReducer, }); } @@ -89,6 +80,7 @@ function registerSagas(sagaMiddleware: SagaMiddleware, deps: GraphStoreD sagaMiddleware.run(syncBreadcrumbSaga(deps)); sagaMiddleware.run(syncTemplatesSaga(deps)); sagaMiddleware.run(fillWorkspaceSaga(deps)); + sagaMiddleware.run(submitSearchSaga(deps)); } export const createGraphStore = (deps: GraphStoreDependencies) => { diff --git a/x-pack/plugins/graph/public/state_management/url_templates.ts b/x-pack/plugins/graph/public/state_management/url_templates.ts index e8f5308534e28..01b1a9296b0b6 100644 --- a/x-pack/plugins/graph/public/state_management/url_templates.ts +++ b/x-pack/plugins/graph/public/state_management/url_templates.ts @@ -10,7 +10,7 @@ import { reducerWithInitialState } from 'typescript-fsa-reducers/dist'; import { i18n } from '@kbn/i18n'; import { modifyUrl } from '@kbn/std'; import rison from 'rison-node'; -import { takeEvery, select } from 'redux-saga/effects'; +import { takeEvery } from 'redux-saga/effects'; import { format, parse } from 'url'; import { GraphState, GraphStoreDependencies } from './store'; import { UrlTemplate } from '../types'; @@ -102,11 +102,9 @@ export const templatesSelector = (state: GraphState) => state.urlTemplates; * * Won't be necessary once the side bar is moved to redux */ -export const syncTemplatesSaga = ({ setUrlTemplates, notifyAngular }: GraphStoreDependencies) => { +export const syncTemplatesSaga = ({ notifyReact }: GraphStoreDependencies) => { function* syncTemplates() { - const templates = templatesSelector(yield select()); - setUrlTemplates(templates); - notifyAngular(); + notifyReact(); } return function* () { diff --git a/x-pack/plugins/graph/public/state_management/workspace.ts b/x-pack/plugins/graph/public/state_management/workspace.ts index 4e0e481a05c17..9e8cca488e4ef 100644 --- a/x-pack/plugins/graph/public/state_management/workspace.ts +++ b/x-pack/plugins/graph/public/state_management/workspace.ts @@ -5,16 +5,41 @@ * 2.0. */ -import actionCreatorFactory from 'typescript-fsa'; +import actionCreatorFactory, { Action } from 'typescript-fsa'; import { i18n } from '@kbn/i18n'; -import { takeLatest, select, call } from 'redux-saga/effects'; -import { GraphStoreDependencies, GraphState } from '.'; +import { takeLatest, select, call, put } from 'redux-saga/effects'; +import { reducerWithInitialState } from 'typescript-fsa-reducers'; +import { createSelector } from 'reselect'; +import { GraphStoreDependencies, GraphState, fillWorkspace } from '.'; +import { reset } from './global'; import { datasourceSelector } from './datasource'; -import { selectedFieldsSelector } from './fields'; +import { liveResponseFieldsSelector, selectedFieldsSelector } from './fields'; import { fetchTopNodes } from '../services/fetch_top_nodes'; -const actionCreator = actionCreatorFactory('x-pack/graph'); +import { Workspace } from '../types'; -export const fillWorkspace = actionCreator('FILL_WORKSPACE'); +const actionCreator = actionCreatorFactory('x-pack/graph/workspace'); + +export interface WorkspaceState { + isInitialized: boolean; +} + +const initialWorkspaceState: WorkspaceState = { + isInitialized: false, +}; + +export const initializeWorkspace = actionCreator('INITIALIZE_WORKSPACE'); +export const submitSearch = actionCreator('SUBMIT_SEARCH'); + +export const workspaceReducer = reducerWithInitialState(initialWorkspaceState) + .case(reset, () => ({ isInitialized: false })) + .case(initializeWorkspace, () => ({ isInitialized: true })) + .build(); + +export const workspaceSelector = (state: GraphState) => state.workspace; +export const workspaceInitializedSelector = createSelector( + workspaceSelector, + (workspace: WorkspaceState) => workspace.isInitialized +); /** * Saga handling filling in top terms into workspace. @@ -23,8 +48,7 @@ export const fillWorkspace = actionCreator('FILL_WORKSPACE'); */ export const fillWorkspaceSaga = ({ getWorkspace, - setWorkspaceInitialized, - notifyAngular, + notifyReact, http, notifications, }: GraphStoreDependencies) => { @@ -47,8 +71,8 @@ export const fillWorkspaceSaga = ({ nodes: topTermNodes, edges: [], }); - setWorkspaceInitialized(); - notifyAngular(); + yield put(initializeWorkspace()); + notifyReact(); workspace.fillInGraph(fields.length * 10); } catch (e) { const message = 'body' in e ? e.body.message : e.message; @@ -65,3 +89,39 @@ export const fillWorkspaceSaga = ({ yield takeLatest(fillWorkspace.match, fetchNodes); }; }; + +export const submitSearchSaga = ({ + getWorkspace, + handleSearchQueryError, +}: GraphStoreDependencies) => { + function* submit(action: Action) { + const searchTerm = action.payload; + yield put(initializeWorkspace()); + + // type casting is safe, at this point workspace should be loaded + const workspace = getWorkspace() as Workspace; + const numHops = 2; + const liveResponseFields = liveResponseFieldsSelector(yield select()); + + if (searchTerm.startsWith('{')) { + try { + const query = JSON.parse(searchTerm); + if (query.vertices) { + // Is a graph explore request + workspace.callElasticsearch(query); + } else { + // Is a regular query DSL query + workspace.search(query, liveResponseFields, numHops); + } + } catch (err) { + handleSearchQueryError(err); + } + return; + } + workspace.simpleSearch(searchTerm, liveResponseFields, numHops); + } + + return function* () { + yield takeLatest(submitSearch.match, submit); + }; +}; diff --git a/x-pack/plugins/graph/public/types/persistence.ts b/x-pack/plugins/graph/public/types/persistence.ts index 46d711de04205..640348d96f6ac 100644 --- a/x-pack/plugins/graph/public/types/persistence.ts +++ b/x-pack/plugins/graph/public/types/persistence.ts @@ -53,15 +53,15 @@ export interface SerializedField extends Omit { +export interface SerializedNode extends Pick { field: string; term: string; parent: number | null; size: number; } -export interface SerializedEdge extends Omit { +export interface SerializedEdge + extends Omit { source: number; target: number; } diff --git a/x-pack/plugins/graph/public/types/workspace_state.ts b/x-pack/plugins/graph/public/types/workspace_state.ts index 86f05376b9526..bca94a7cfad6d 100644 --- a/x-pack/plugins/graph/public/types/workspace_state.ts +++ b/x-pack/plugins/graph/public/types/workspace_state.ts @@ -6,10 +6,13 @@ */ import { JsonObject } from '@kbn/utility-types'; +import d3 from 'd3'; +import { TargetOptions } from '../components/control_panel'; import { FontawesomeIcon } from '../helpers/style_choices'; import { WorkspaceField, AdvancedSettings } from './app_state'; export interface WorkspaceNode { + id: string; x: number; y: number; label: string; @@ -21,9 +24,14 @@ export interface WorkspaceNode { scaledSize: number; parent: WorkspaceNode | null; color: string; + numChildren: number; isSelected?: boolean; + kx: number; + ky: number; } +export type BlockListedNode = Omit; + export interface WorkspaceEdge { weight: number; width: number; @@ -31,6 +39,8 @@ export interface WorkspaceEdge { source: WorkspaceNode; target: WorkspaceNode; isSelected?: boolean; + topTarget: WorkspaceNode; + topSrc: WorkspaceNode; } export interface ServerResultNode { @@ -58,13 +68,59 @@ export interface GraphData { nodes: ServerResultNode[]; edges: ServerResultEdge[]; } +export interface TermIntersect { + id1: string; + id2: string; + term1: string; + term2: string; + v1: number; + v2: number; + overlap: number; +} export interface Workspace { options: WorkspaceOptions; nodesMap: Record; nodes: WorkspaceNode[]; + selectedNodes: WorkspaceNode[]; edges: WorkspaceEdge[]; - blocklistedNodes: WorkspaceNode[]; + blocklistedNodes: BlockListedNode[]; + undoLog: string; + redoLog: string; + force: ReturnType; + lastRequest: string; + lastResponse: string; + + undo: () => void; + redo: () => void; + expandSelecteds: (targetOptions: TargetOptions) => {}; + deleteSelection: () => void; + blocklistSelection: () => void; + selectAll: () => void; + selectNone: () => void; + selectInvert: () => void; + selectNeighbours: () => void; + deselectNode: (node: WorkspaceNode) => void; + colorSelected: (color: string) => void; + groupSelections: (node: WorkspaceNode | undefined) => void; + ungroup: (node: WorkspaceNode | undefined) => void; + callElasticsearch: (request: any) => void; + search: (qeury: any, fieldsChoice: WorkspaceField[] | undefined, numHops: number) => void; + simpleSearch: ( + searchTerm: string, + fieldsChoice: WorkspaceField[] | undefined, + numHops: number + ) => void; + getAllIntersections: ( + callback: (termIntersects: TermIntersect[]) => void, + nodes: WorkspaceNode[] + ) => void; + toggleNodeSelection: (node: WorkspaceNode) => boolean; + mergeIds: (term1: string, term2: string) => void; + changeHandler: () => void; + unblockNode: (node: BlockListedNode) => void; + unblockAll: () => void; + clearGraph: () => void; getQuery(startNodes?: WorkspaceNode[], loose?: boolean): JsonObject; getSelectedOrAllNodes(): WorkspaceNode[]; @@ -96,6 +152,8 @@ export type ExploreRequest = any; export type SearchRequest = any; export type ExploreResults = any; export type SearchResults = any; +export type GraphExploreCallback = (data: ExploreResults) => void; +export type GraphSearchCallback = (data: SearchResults) => void; export type WorkspaceOptions = Partial<{ indexName: string; @@ -105,12 +163,14 @@ export type WorkspaceOptions = Partial<{ graphExploreProxy: ( indexPattern: string, request: ExploreRequest, - callback: (data: ExploreResults) => void + callback: GraphExploreCallback ) => void; searchProxy: ( indexPattern: string, request: SearchRequest, - callback: (data: SearchResults) => void + callback: GraphSearchCallback ) => void; exploreControls: AdvancedSettings; }>; + +export type ControlType = 'style' | 'drillDowns' | 'editLabel' | 'mergeTerms' | 'none'; diff --git a/x-pack/plugins/index_management/common/index.ts b/x-pack/plugins/index_management/common/index.ts index 1b1a10156abfc..ed8fd87643946 100644 --- a/x-pack/plugins/index_management/common/index.ts +++ b/x-pack/plugins/index_management/common/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/110892 +/* eslint-disable @kbn/eslint/no_export_all */ + export { API_BASE_PATH, BASE_PATH } from './constants'; export { getTemplateParameter } from './lib'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx index a8ccb0f5119c8..e7ace1aff3101 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx @@ -94,17 +94,25 @@ export const useMappingsStateListener = ({ onChange, value }: Args) => { validate: async () => { const configurationFormValidator = state.configuration.submitForm !== undefined - ? new Promise(async (resolve) => { - const { isValid } = await state.configuration.submitForm!(); - resolve(isValid); + ? new Promise(async (resolve, reject) => { + try { + const { isValid } = await state.configuration.submitForm!(); + resolve(isValid); + } catch (error) { + reject(error); + } }) : Promise.resolve(true); const templatesFormValidator = state.templates.submitForm !== undefined - ? new Promise(async (resolve) => { - const { isValid } = await state.templates.submitForm!(); - resolve(isValid); + ? new Promise(async (resolve, reject) => { + try { + const { isValid } = await state.templates.submitForm!(); + resolve(isValid); + } catch (error) { + reject(error); + } }) : Promise.resolve(true); diff --git a/x-pack/plugins/lens/common/index.ts b/x-pack/plugins/lens/common/index.ts index 42e673058f1db..e0600bd18afc1 100644 --- a/x-pack/plugins/lens/common/index.ts +++ b/x-pack/plugins/lens/common/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/110891 +/* eslint-disable @kbn/eslint/no_export_all */ + export * from './api'; export * from './constants'; export * from './types'; diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 9565bf57a315f..2ec7a1962da82 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -41,8 +41,8 @@ "fieldFormats" ], "owner": { - "name": "Kibana App", - "githubTeam": "kibana-app" + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" }, "description": "Visualization editor allowing to quickly and easily configure compelling visualizations to use on dashboards and canvas workpads. Exposes components to embed visualizations and link into the Lens editor from within other apps in Kibana." } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index cfe190261b53b..6489a43cf2f85 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -348,27 +348,19 @@ export const termsOperation: OperationDefinition - - { - updateLayer( - updateColumnParam({ - layer, - columnId, - paramName: 'size', - value, - }) - ); - }} - /> - + { + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'size', + value, + }) + ); + }} + /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx index 4303695d6e293..ac7397fb582ab 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow } from 'enzyme'; -import { EuiFieldNumber } from '@elastic/eui'; +import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; import { ValuesInput } from './values_input'; jest.mock('react-use/lib/useDebounce', () => (fn: () => void) => fn()); @@ -41,7 +41,7 @@ describe('Values', () => { expect(onChangeSpy.mock.calls[0][0]).toBe(7); }); - it('should not run onChange function on update when value is out of 1-100 range', () => { + it('should not run onChange function on update when value is out of 1-1000 range', () => { const onChangeSpy = jest.fn(); const instance = shallow(); act(() => { @@ -54,4 +54,56 @@ describe('Values', () => { expect(onChangeSpy.mock.calls.length).toBe(1); expect(onChangeSpy.mock.calls[0][0]).toBe(1000); }); + + it('should show an error message when the value is out of bounds', () => { + const instance = shallow(); + + expect(instance.find(EuiFieldNumber).prop('isInvalid')).toBeTruthy(); + expect(instance.find(EuiFormRow).prop('error')).toEqual( + expect.arrayContaining([expect.stringMatching('Value is lower')]) + ); + + act(() => { + instance.find(EuiFieldNumber).prop('onChange')!({ + currentTarget: { value: '1007' }, + } as React.ChangeEvent); + }); + instance.update(); + + expect(instance.find(EuiFieldNumber).prop('isInvalid')).toBeTruthy(); + expect(instance.find(EuiFormRow).prop('error')).toEqual( + expect.arrayContaining([expect.stringMatching('Value is higher')]) + ); + }); + + it('should fallback to last valid value on input blur', () => { + const instance = shallow(); + + function changeAndBlur(newValue: string) { + act(() => { + instance.find(EuiFieldNumber).prop('onChange')!({ + currentTarget: { value: newValue }, + } as React.ChangeEvent); + }); + instance.update(); + act(() => { + instance.find(EuiFieldNumber).prop('onBlur')!({} as React.FocusEvent); + }); + instance.update(); + } + + changeAndBlur('-5'); + + expect(instance.find(EuiFieldNumber).prop('isInvalid')).toBeFalsy(); + expect(instance.find(EuiFieldNumber).prop('value')).toBe('1'); + + changeAndBlur('5000'); + + expect(instance.find(EuiFieldNumber).prop('isInvalid')).toBeFalsy(); + expect(instance.find(EuiFieldNumber).prop('value')).toBe('1000'); + + changeAndBlur(''); + // as we're not handling the onChange state, it fallbacks to the value prop + expect(instance.find(EuiFieldNumber).prop('value')).toBe('123'); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx index a4c0f8f1c50e0..96b92686f7622 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFieldNumber } from '@elastic/eui'; +import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; import { useDebounceWithOptions } from '../../../../shared_components'; export const ValuesInput = ({ @@ -35,17 +35,63 @@ export const ValuesInput = ({ [inputValue] ); + const isEmptyString = inputValue === ''; + const isHigherThanMax = !isEmptyString && Number(inputValue) > MAX_NUMBER_OF_VALUES; + const isLowerThanMin = !isEmptyString && Number(inputValue) < MIN_NUMBER_OF_VALUES; + return ( - setInputValue(currentTarget.value)} - aria-label={i18n.translate('xpack.lens.indexPattern.terms.size', { + + display="columnCompressed" + fullWidth + isInvalid={isHigherThanMax || isLowerThanMin} + error={ + isHigherThanMax + ? [ + i18n.translate('xpack.lens.indexPattern.terms.sizeLimitMax', { + defaultMessage: + 'Value is higher than the maximum {max}, the maximum value is used instead.', + values: { + max: MAX_NUMBER_OF_VALUES, + }, + }), + ] + : isLowerThanMin + ? [ + i18n.translate('xpack.lens.indexPattern.terms.sizeLimitMin', { + defaultMessage: + 'Value is lower than the minimum {min}, the minimum value is used instead.', + values: { + min: MIN_NUMBER_OF_VALUES, + }, + }), + ] + : null + } + > + setInputValue(currentTarget.value)} + aria-label={i18n.translate('xpack.lens.indexPattern.terms.size', { + defaultMessage: 'Number of values', + })} + onBlur={() => { + if (inputValue === '') { + return setInputValue(String(value)); + } + const inputNumber = Number(inputValue); + setInputValue( + String(Math.min(MAX_NUMBER_OF_VALUES, Math.max(inputNumber, MIN_NUMBER_OF_VALUES))) + ); + }} + /> + ); }; diff --git a/x-pack/plugins/lens/server/index.ts b/x-pack/plugins/lens/server/index.ts index f8a9b2452de41..08f1eb1562739 100644 --- a/x-pack/plugins/lens/server/index.ts +++ b/x-pack/plugins/lens/server/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/110891 +/* eslint-disable @kbn/eslint/no_export_all */ + import { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; import { LensServerPlugin } from './plugin'; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index f81ec6a73d140..6591afafff00f 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -718,18 +718,8 @@ exports[`UploadLicense should display a modal when license requires acknowledgem onChange={[Function]} >
new LicensingPlugin(context); diff --git a/x-pack/plugins/licensing/public/services/index.ts b/x-pack/plugins/licensing/public/services/index.ts index f7e125a253293..19c7b58dbb9c4 100644 --- a/x-pack/plugins/licensing/public/services/index.ts +++ b/x-pack/plugins/licensing/public/services/index.ts @@ -5,8 +5,5 @@ * 2.0. */ -export { - FeatureUsageService, - FeatureUsageServiceSetup, - FeatureUsageServiceStart, -} from './feature_usage_service'; +export { FeatureUsageService } from './feature_usage_service'; +export type { FeatureUsageServiceSetup, FeatureUsageServiceStart } from './feature_usage_service'; diff --git a/x-pack/plugins/licensing/server/index.ts b/x-pack/plugins/licensing/server/index.ts index b591263b48675..a95842308e4eb 100644 --- a/x-pack/plugins/licensing/server/index.ts +++ b/x-pack/plugins/licensing/server/index.ts @@ -10,8 +10,30 @@ import { LicensingPlugin } from './plugin'; export const plugin = (context: PluginInitializerContext) => new LicensingPlugin(context); -export * from '../common/types'; -export { FeatureUsageServiceSetup, FeatureUsageServiceStart } from './services'; -export * from './types'; +export type { + LicenseCheckState, + LicenseType, + LicenseStatus, + LicenseFeature, + PublicLicense, + PublicFeatures, + PublicLicenseJSON, + LicenseCheck, + ILicense, +} from '../common/types'; + +export { LICENSE_TYPE } from '../common/types'; + +export type { FeatureUsageServiceSetup, FeatureUsageServiceStart } from './services'; + +export type { + ElasticsearchError, + LicensingApiRequestHandlerContext, + LicensingPluginSetup, + LicensingPluginStart, +} from './types'; + export { config } from './licensing_config'; -export { CheckLicense, wrapRouteWithLicenseCheck } from './wrap_route_with_license_check'; + +export type { CheckLicense } from './wrap_route_with_license_check'; +export { wrapRouteWithLicenseCheck } from './wrap_route_with_license_check'; diff --git a/x-pack/plugins/licensing/server/services/index.ts b/x-pack/plugins/licensing/server/services/index.ts index f7e125a253293..19c7b58dbb9c4 100644 --- a/x-pack/plugins/licensing/server/services/index.ts +++ b/x-pack/plugins/licensing/server/services/index.ts @@ -5,8 +5,5 @@ * 2.0. */ -export { - FeatureUsageService, - FeatureUsageServiceSetup, - FeatureUsageServiceStart, -} from './feature_usage_service'; +export { FeatureUsageService } from './feature_usage_service'; +export type { FeatureUsageServiceSetup, FeatureUsageServiceStart } from './feature_usage_service'; diff --git a/x-pack/plugins/licensing/tsconfig.json b/x-pack/plugins/licensing/tsconfig.json index d8855fcd65912..355d99fa461b8 100644 --- a/x-pack/plugins/licensing/tsconfig.json +++ b/x-pack/plugins/licensing/tsconfig.json @@ -4,7 +4,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true, }, "include": [ "public/**/*", diff --git a/x-pack/plugins/lists/public/index.ts b/x-pack/plugins/lists/public/index.ts index 0b67ab05f5bd4..977b7f462777e 100644 --- a/x-pack/plugins/lists/public/index.ts +++ b/x-pack/plugins/lists/public/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/110903 +/* eslint-disable @kbn/eslint/no_export_all */ + export * from './shared_exports'; import type { PluginInitializerContext } from '../../../../src/core/public'; diff --git a/x-pack/plugins/maps/common/index.ts b/x-pack/plugins/maps/common/index.ts index f4d74984a7d78..7c551b3ed9eb4 100644 --- a/x-pack/plugins/maps/common/index.ts +++ b/x-pack/plugins/maps/common/index.ts @@ -5,5 +5,8 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/109853 +/* eslint-disable @kbn/eslint/no_export_all */ + export * from './constants'; export * from './types'; diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 45f3299db9041..c1db14347460f 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -6,6 +6,7 @@ */ import _ from 'lodash'; +import { i18n } from '@kbn/i18n'; import { AnyAction, Dispatch } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; import turfBboxPolygon from '@turf/bbox-polygon'; @@ -59,6 +60,7 @@ import { cleanTooltipStateForLayer } from './tooltip_actions'; import { VectorLayer } from '../classes/layers/vector_layer'; import { SET_DRAW_MODE } from './ui_actions'; import { expandToTileBoundaries } from '../../common/geo_tile_utils'; +import { getToasts } from '../kibana_services'; export function setMapInitError(errorMessage: string) { return { @@ -367,8 +369,17 @@ export function addNewFeatureToIndex(geometry: Geometry | Position[]) { if (!layer || !(layer instanceof VectorLayer)) { return; } - await layer.addFeature(geometry); - await dispatch(syncDataForLayer(layer, true)); + + try { + await layer.addFeature(geometry); + await dispatch(syncDataForLayer(layer, true)); + } catch (e) { + getToasts().addError(e, { + title: i18n.translate('xpack.maps.mapActions.addFeatureError', { + defaultMessage: `Unable to add feature to index.`, + }), + }); + } }; } @@ -386,7 +397,15 @@ export function deleteFeatureFromIndex(featureId: string) { if (!layer || !(layer instanceof VectorLayer)) { return; } - await layer.deleteFeature(featureId); - await dispatch(syncDataForLayer(layer, true)); + try { + await layer.deleteFeature(featureId); + await dispatch(syncDataForLayer(layer, true)); + } catch (e) { + getToasts().addError(e, { + title: i18n.translate('xpack.maps.mapActions.removeFeatureError', { + defaultMessage: `Unable to remove feature from index.`, + }), + }); + } }; } diff --git a/x-pack/plugins/maps/public/classes/layers/new_vector_layer_wizard/wizard.tsx b/x-pack/plugins/maps/public/classes/layers/new_vector_layer_wizard/wizard.tsx index 6dac22581efdb..100e9dfa45c1d 100644 --- a/x-pack/plugins/maps/public/classes/layers/new_vector_layer_wizard/wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/new_vector_layer_wizard/wizard.tsx @@ -13,13 +13,14 @@ import { RenderWizardArguments } from '../layer_wizard_registry'; import { VectorLayer } from '../vector_layer'; import { ESSearchSource } from '../../sources/es_search_source'; import { ADD_LAYER_STEP_ID } from '../../../connected_components/add_layer_panel/view'; -import { getIndexNameFormComponent } from '../../../kibana_services'; +import { getFileUpload, getIndexNameFormComponent } from '../../../kibana_services'; interface State { indexName: string; indexNameError: string; indexingTriggered: boolean; createIndexError: string; + userHasIndexWritePermissions: boolean; } const DEFAULT_MAPPINGS = { @@ -43,6 +44,7 @@ export class NewVectorLayerEditor extends Component { let indexPatternId: string | undefined; try { + const userHasIndexWritePermissions = await this._checkIndexPermissions(); + if (!userHasIndexWritePermissions) { + this._setCreateIndexError( + i18n.translate('xpack.maps.layers.newVectorLayerWizard.indexPermissionsError', { + defaultMessage: `You must have 'create' and 'create_index' index privileges to create and write data to "{indexName}".`, + values: { + indexName: this.state.indexName, + }, + }), + userHasIndexWritePermissions + ); + return; + } const response = await createNewIndexAndPattern({ indexName: this.state.indexName, defaultMappings: DEFAULT_MAPPINGS, @@ -125,10 +149,23 @@ export class NewVectorLayerEditor extends Component +

{this.state.createIndexError}

+ + ); + } return ( { + async getSourceIndexList(): Promise { await this.getIndexPattern(); if (!(this.indexPattern && this.indexPattern.title)) { - return false; + return []; } - const { matchingIndexes } = await getMatchingIndexes(this.indexPattern.title); - if (!matchingIndexes) { - return false; + let success; + let matchingIndexes; + try { + ({ success, matchingIndexes } = await getMatchingIndexes(this.indexPattern.title)); + } catch (e) { + // Fail silently } + return success ? matchingIndexes : []; + } + + async supportsFeatureEditing(): Promise { + const matchingIndexes = await this.getSourceIndexList(); // For now we only support 1:1 index-pattern:index matches return matchingIndexes.length === 1; } @@ -749,17 +757,36 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye return MVT_SOURCE_LAYER_NAME; } + async _getEditableIndex(): Promise { + const indexList = await this.getSourceIndexList(); + if (indexList.length === 0) { + throw new Error( + i18n.translate('xpack.maps.source.esSearch.indexZeroLengthEditError', { + defaultMessage: `Your index pattern doesn't point to any indices.`, + }) + ); + } + if (indexList.length > 1) { + throw new Error( + i18n.translate('xpack.maps.source.esSearch.indexOverOneLengthEditError', { + defaultMessage: `Your index pattern points to multiple indices. Only one index is allowed per index pattern.`, + }) + ); + } + return indexList[0]; + } + async addFeature( geometry: Geometry | Position[], defaultFields: Record> ) { - const indexPattern = await this.getIndexPattern(); - await addFeatureToIndex(indexPattern.title, geometry, this.getGeoFieldName(), defaultFields); + const index = await this._getEditableIndex(); + await addFeatureToIndex(index, geometry, this.getGeoFieldName(), defaultFields); } async deleteFeature(featureId: string) { - const indexPattern = await this.getIndexPattern(); - await deleteFeatureFromIndex(indexPattern.title, featureId); + const index = await this._getEditableIndex(); + await deleteFeatureFromIndex(index, featureId); } async getUrlTemplateWithMeta( diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/util/feature_edit.ts b/x-pack/plugins/maps/public/classes/sources/es_search_source/util/feature_edit.ts index c9a967bea3e2c..08ba33a72363f 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/util/feature_edit.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/util/feature_edit.ts @@ -43,8 +43,9 @@ export const deleteFeatureFromIndex = async (indexName: string, featureId: strin export const getMatchingIndexes = async (indexPattern: string) => { return await getHttp().fetch({ - path: `${GET_MATCHING_INDEXES_PATH}/${indexPattern}`, + path: GET_MATCHING_INDEXES_PATH, method: 'GET', + query: { indexPattern }, }); }; diff --git a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts index 788e5938ee168..7157b145f828d 100644 --- a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts @@ -70,36 +70,39 @@ export async function lazyLoadMapModules(): Promise { return loadModulesPromise; } - loadModulesPromise = new Promise(async (resolve) => { - const { - MapEmbeddable, - getIndexPatternService, - getMapsCapabilities, - renderApp, - createSecurityLayerDescriptors, - registerLayerWizard, - registerSource, - createTileMapLayerDescriptor, - createRegionMapLayerDescriptor, - createBasemapLayerDescriptor, - createESSearchSourceLayerDescriptor, - suggestEMSTermJoinConfig, - } = await import('./lazy'); - - resolve({ - MapEmbeddable, - getIndexPatternService, - getMapsCapabilities, - renderApp, - createSecurityLayerDescriptors, - registerLayerWizard, - registerSource, - createTileMapLayerDescriptor, - createRegionMapLayerDescriptor, - createBasemapLayerDescriptor, - createESSearchSourceLayerDescriptor, - suggestEMSTermJoinConfig, - }); + loadModulesPromise = new Promise(async (resolve, reject) => { + try { + const { + MapEmbeddable, + getIndexPatternService, + getMapsCapabilities, + renderApp, + createSecurityLayerDescriptors, + registerLayerWizard, + registerSource, + createTileMapLayerDescriptor, + createRegionMapLayerDescriptor, + createBasemapLayerDescriptor, + createESSearchSourceLayerDescriptor, + suggestEMSTermJoinConfig, + } = await import('./lazy'); + resolve({ + MapEmbeddable, + getIndexPatternService, + getMapsCapabilities, + renderApp, + createSecurityLayerDescriptors, + registerLayerWizard, + registerSource, + createTileMapLayerDescriptor, + createRegionMapLayerDescriptor, + createBasemapLayerDescriptor, + createESSearchSourceLayerDescriptor, + suggestEMSTermJoinConfig, + }); + } catch (error) { + reject(error); + } }); return loadModulesPromise; } diff --git a/x-pack/plugins/maps/server/data_indexing/get_indexes_matching_pattern.ts b/x-pack/plugins/maps/server/data_indexing/get_indexes_matching_pattern.ts index c8b55ffe2e087..e09063f99ec8c 100644 --- a/x-pack/plugins/maps/server/data_indexing/get_indexes_matching_pattern.ts +++ b/x-pack/plugins/maps/server/data_indexing/get_indexes_matching_pattern.ts @@ -5,13 +5,14 @@ * 2.0. */ -import { IScopedClusterClient } from 'kibana/server'; -import { MatchingIndexesResp } from '../../common'; +import { IScopedClusterClient, KibanaResponseFactory, Logger } from 'kibana/server'; export async function getMatchingIndexes( indexPattern: string, - { asCurrentUser }: IScopedClusterClient -): Promise { + { asCurrentUser }: IScopedClusterClient, + response: KibanaResponseFactory, + logger: Logger +) { try { const { body: indexResults } = await asCurrentUser.cat.indices({ index: indexPattern, @@ -20,14 +21,20 @@ export async function getMatchingIndexes( const matchingIndexes = indexResults .map((indexRecord) => indexRecord.index) .filter((indexName) => !!indexName); - return { - success: true, - matchingIndexes: matchingIndexes as string[], - }; + return response.ok({ body: { success: true, matchingIndexes: matchingIndexes as string[] } }); } catch (error) { - return { - success: false, - error, - }; + const errorStatusCode = error.meta?.statusCode; + if (errorStatusCode === 404) { + return response.ok({ body: { success: true, matchingIndexes: [] } }); + } else { + logger.error(error); + return response.custom({ + body: { + success: false, + message: `Error accessing indexes: ${error.meta?.body?.error?.type}`, + }, + statusCode: 200, + }); + } } } diff --git a/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts b/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts index 52dd1c56d2435..baba176286ee2 100644 --- a/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts +++ b/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts @@ -163,19 +163,20 @@ export function initIndexingRoutes({ router.get( { - path: `${GET_MATCHING_INDEXES_PATH}/{indexPattern}`, + path: GET_MATCHING_INDEXES_PATH, validate: { - params: schema.object({ + query: schema.object({ indexPattern: schema.string(), }), }, }, async (context, request, response) => { - const result = await getMatchingIndexes( - request.params.indexPattern, - context.core.elasticsearch.client + return await getMatchingIndexes( + request.query.indexPattern, + context.core.elasticsearch.client, + response, + logger ); - return response.ok({ body: result }); } ); diff --git a/x-pack/plugins/metrics_entities/common/index.ts b/x-pack/plugins/metrics_entities/common/index.ts index a532dc151bf46..a6630f3ff67b0 100644 --- a/x-pack/plugins/metrics_entities/common/index.ts +++ b/x-pack/plugins/metrics_entities/common/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/110904 +/* eslint-disable @kbn/eslint/no_export_all */ + export const PLUGIN_ID = 'metricsEntities'; export const PLUGIN_NAME = 'metrics_entities'; diff --git a/x-pack/plugins/ml/.gitignore b/x-pack/plugins/ml/.gitignore index 708c5b199467b..e0f20bbc48bda 100644 --- a/x-pack/plugins/ml/.gitignore +++ b/x-pack/plugins/ml/.gitignore @@ -1 +1,2 @@ routes_doc +server/routes/apidoc_scripts/header.md diff --git a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx index 2fa81504f93cb..73eb91ffd30a8 100644 --- a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx @@ -8,34 +8,22 @@ import React, { FC, useCallback, useMemo } from 'react'; import { EuiCheckbox, htmlIdGenerator } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { useExplorerUrlState } from '../../../explorer/hooks/use_explorer_url_state'; -const SHOW_CHARTS_DEFAULT = true; - -export const useShowCharts = (): [boolean, (v: boolean) => void] => { - const [explorerUrlState, setExplorerUrlState] = useExplorerUrlState(); - - const showCharts = explorerUrlState?.mlShowCharts ?? SHOW_CHARTS_DEFAULT; - - const setShowCharts = useCallback( - (v: boolean) => { - setExplorerUrlState({ mlShowCharts: v }); - }, - [setExplorerUrlState] - ); - - return [showCharts, setShowCharts]; -}; +export interface CheckboxShowChartsProps { + showCharts: boolean; + setShowCharts: (update: boolean) => void; +} /* * React component for a checkbox element to toggle charts display. */ -export const CheckboxShowCharts: FC = () => { - const [showCharts, setShowCharts] = useShowCharts(); - - const onChange = (e: React.ChangeEvent) => { - setShowCharts(e.target.checked); - }; +export const CheckboxShowCharts: FC = ({ showCharts, setShowCharts }) => { + const onChange = useCallback( + (e: React.ChangeEvent) => { + setShowCharts(e.target.checked); + }, + [setShowCharts] + ); const id = useMemo(() => htmlIdGenerator()(), []); diff --git a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/index.ts b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/index.ts index 3ff95bf6e335c..2099abb168283 100644 --- a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/index.ts +++ b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { useShowCharts, CheckboxShowCharts } from './checkbox_showcharts'; +export { CheckboxShowCharts } from './checkbox_showcharts'; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index c9365c4edbe5f..daecf7585b3ea 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -498,7 +498,10 @@ export class ExplorerUI extends React.Component { {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && ( - + )} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index d737c4733b9cb..cd01de31e5e60 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -34,6 +34,7 @@ export const EXPLORER_ACTION = { SET_VIEW_BY_PER_PAGE: 'setViewByPerPage', SET_VIEW_BY_FROM_PAGE: 'setViewByFromPage', SET_SWIM_LANE_SEVERITY: 'setSwimLaneSeverity', + SET_SHOW_CHARTS: 'setShowCharts', }; export const FILTER_ACTION = { diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index f858c40b32315..1d4a277af0131 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts @@ -83,6 +83,10 @@ const explorerAppState$: Observable = explorerState$.pipe( appState.mlExplorerSwimlane.severity = state.swimLaneSeverity; } + if (state.showCharts !== undefined) { + appState.mlShowCharts = state.showCharts; + } + if (state.filterActive) { appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery; appState.mlExplorerFilter.filterActive = state.filterActive; @@ -168,6 +172,9 @@ export const explorerService = { setSwimLaneSeverity: (payload: number) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_SWIM_LANE_SEVERITY, payload }); }, + setShowCharts: (payload: boolean) => { + explorerAction$.next({ type: EXPLORER_ACTION.SET_SHOW_CHARTS, payload }); + }, }; export type ExplorerService = typeof explorerService; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts index 74867af5f8987..192699afc2cf4 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts @@ -158,6 +158,13 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo }; break; + case EXPLORER_ACTION.SET_SHOW_CHARTS: + nextState = { + ...state, + showCharts: payload, + }; + break; + default: nextState = state; } diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index a06db20210c1b..202a4389ef524 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -59,6 +59,7 @@ export interface ExplorerState { viewBySwimlaneOptions: string[]; swimlaneLimit?: number; swimLaneSeverity?: number; + showCharts: boolean; } function getDefaultIndexPattern() { @@ -112,5 +113,6 @@ export function getExplorerDefaultState(): ExplorerState { viewByPerPage: SWIM_LANE_DEFAULT_PAGE_SIZE, viewByFromPage: 1, swimlaneLimit: undefined, + showCharts: true, }; } diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 42927d9b4ef50..49e7857eee082 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -26,7 +26,6 @@ import { useExplorerData } from '../../explorer/actions'; import { explorerService } from '../../explorer/explorer_dashboard_service'; import { getDateFormatTz } from '../../explorer/explorer_utils'; import { useJobSelection } from '../../components/job_selector/use_job_selection'; -import { useShowCharts } from '../../components/controls/checkbox_showcharts'; import { useTableInterval } from '../../components/controls/select_interval'; import { useTableSeverity } from '../../components/controls/select_severity'; import { useUrlState } from '../../util/url_state'; @@ -196,6 +195,10 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim if (severity !== undefined) { explorerService.setSwimLaneSeverity(severity); } + + if (explorerUrlState.mlShowCharts !== undefined) { + explorerService.setShowCharts(explorerUrlState.mlShowCharts); + } }, []); /** Sync URL state with {@link explorerService} state */ @@ -214,7 +217,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim } }, [explorerData]); - const [showCharts] = useShowCharts(); const [tableInterval] = useTableInterval(); const [tableSeverity] = useTableSeverity(); @@ -267,7 +269,11 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim } }, [JSON.stringify(loadExplorerDataConfig), selectedCells?.showTopFieldValues]); - if (explorerState === undefined || refresh === undefined || showCharts === undefined) { + if ( + explorerState === undefined || + refresh === undefined || + explorerAppState?.mlShowCharts === undefined + ) { return null; } @@ -277,7 +283,7 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim {...{ explorerState, setSelectedCells, - showCharts, + showCharts: explorerState.showCharts, severity: tableSeverity.val, stoppedPartitions, invalidTimeRangeError, diff --git a/x-pack/plugins/ml/public/application/services/new_job_capabilities/load_new_job_capabilities.ts b/x-pack/plugins/ml/public/application/services/new_job_capabilities/load_new_job_capabilities.ts index a998343535249..d3b407c2bb65a 100644 --- a/x-pack/plugins/ml/public/application/services/new_job_capabilities/load_new_job_capabilities.ts +++ b/x-pack/plugins/ml/public/application/services/new_job_capabilities/load_new_job_capabilities.ts @@ -23,27 +23,34 @@ export function loadNewJobCapabilities( jobType: JobType ) { return new Promise(async (resolve, reject) => { - const serviceToUse = - jobType === ANOMALY_DETECTOR ? newJobCapsService : newJobCapsServiceAnalytics; - if (indexPatternId !== undefined) { - // index pattern is being used - const indexPattern: IIndexPattern = await indexPatterns.get(indexPatternId); - await serviceToUse.initializeFromIndexPattern(indexPattern); - resolve(serviceToUse.newJobCaps); - } else if (savedSearchId !== undefined) { - // saved search is being used - // load the index pattern from the saved search - const { indexPattern } = await getIndexPatternAndSavedSearch(savedSearchId); - if (indexPattern === null) { - // eslint-disable-next-line no-console - console.error('Cannot retrieve index pattern from saved search'); + try { + const serviceToUse = + jobType === ANOMALY_DETECTOR ? newJobCapsService : newJobCapsServiceAnalytics; + + if (indexPatternId !== undefined) { + // index pattern is being used + const indexPattern: IIndexPattern = await indexPatterns.get(indexPatternId); + await serviceToUse.initializeFromIndexPattern(indexPattern); + resolve(serviceToUse.newJobCaps); + } else if (savedSearchId !== undefined) { + // saved search is being used + // load the index pattern from the saved search + const { indexPattern } = await getIndexPatternAndSavedSearch(savedSearchId); + + if (indexPattern === null) { + // eslint-disable-next-line no-console + console.error('Cannot retrieve index pattern from saved search'); + reject(); + return; + } + + await serviceToUse.initializeFromIndexPattern(indexPattern); + resolve(serviceToUse.newJobCaps); + } else { reject(); - return; } - await serviceToUse.initializeFromIndexPattern(indexPattern); - resolve(serviceToUse.newJobCaps); - } else { - reject(); + } catch (error) { + reject(error); } }); } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx index eb39ba4ab29aa..5090274ca7383 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx @@ -25,33 +25,34 @@ export async function resolveEmbeddableAnomalyChartsUserInput( const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http)); return new Promise(async (resolve, reject) => { - const { jobIds } = await resolveJobSelection(coreStart, input?.jobIds); - - const title = input?.title ?? getDefaultExplorerChartsPanelTitle(jobIds); - const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); - const influencers = anomalyDetectorService.extractInfluencers(jobs); - influencers.push(VIEW_BY_JOB_LABEL); - - const modalSession = overlays.openModal( - toMountPoint( - { - modalSession.close(); - - resolve({ - jobIds, - title: panelTitle, - maxSeriesToPlot, - }); - }} - onCancel={() => { - modalSession.close(); - reject(); - }} - /> - ) - ); + try { + const { jobIds } = await resolveJobSelection(coreStart, input?.jobIds); + const title = input?.title ?? getDefaultExplorerChartsPanelTitle(jobIds); + const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); + const influencers = anomalyDetectorService.extractInfluencers(jobs); + influencers.push(VIEW_BY_JOB_LABEL); + const modalSession = overlays.openModal( + toMountPoint( + { + modalSession.close(); + resolve({ + jobIds, + title: panelTitle, + maxSeriesToPlot, + }); + }} + onCancel={() => { + modalSession.close(); + reject(); + }} + /> + ) + ); + } catch (error) { + reject(error); + } }); } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx index e183907def57b..5027eb6783a64 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx @@ -25,31 +25,36 @@ export async function resolveAnomalySwimlaneUserInput( const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http)); return new Promise(async (resolve, reject) => { - const { jobIds } = await resolveJobSelection(coreStart, input?.jobIds); - - const title = input?.title ?? getDefaultSwimlanePanelTitle(jobIds); - - const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); - - const influencers = anomalyDetectorService.extractInfluencers(jobs); - influencers.push(VIEW_BY_JOB_LABEL); - - const modalSession = overlays.openModal( - toMountPoint( - { - modalSession.close(); - resolve({ jobIds, title: panelTitle, swimlaneType, viewBy }); - }} - onCancel={() => { - modalSession.close(); - reject(); - }} - /> - ) - ); + try { + const { jobIds } = await resolveJobSelection(coreStart, input?.jobIds); + const title = input?.title ?? getDefaultSwimlanePanelTitle(jobIds); + const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); + const influencers = anomalyDetectorService.extractInfluencers(jobs); + influencers.push(VIEW_BY_JOB_LABEL); + const modalSession = overlays.openModal( + toMountPoint( + { + modalSession.close(); + resolve({ + jobIds, + title: panelTitle, + swimlaneType, + viewBy, + }); + }} + onCancel={() => { + modalSession.close(); + reject(); + }} + /> + ) + ); + } catch (error) { + reject(error); + } }); } diff --git a/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx b/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx index 1833883447859..fbceeb7f7cf79 100644 --- a/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx +++ b/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx @@ -38,56 +38,65 @@ export async function resolveJobSelection( } = coreStart; return new Promise(async (resolve, reject) => { - const maps = { - groupsMap: getInitialGroupsMap([]), - jobsMap: {}, - }; + try { + const maps = { + groupsMap: getInitialGroupsMap([]), + jobsMap: {}, + }; + const tzConfig = uiSettings.get('dateFormat:tz'); + const dateFormatTz = tzConfig !== 'Browser' ? tzConfig : moment.tz.guess(); - const tzConfig = uiSettings.get('dateFormat:tz'); - const dateFormatTz = tzConfig !== 'Browser' ? tzConfig : moment.tz.guess(); + const onFlyoutClose = () => { + flyoutSession.close(); + reject(); + }; - const onFlyoutClose = () => { - flyoutSession.close(); - reject(); - }; + const onSelectionConfirmed = async ({ + jobIds, + groups, + }: { + jobIds: string[]; + groups: Array<{ + groupId: string; + jobIds: string[]; + }>; + }) => { + await flyoutSession.close(); + resolve({ + jobIds, + groups, + }); + }; - const onSelectionConfirmed = async ({ - jobIds, - groups, - }: { - jobIds: string[]; - groups: Array<{ groupId: string; jobIds: string[] }>; - }) => { - await flyoutSession.close(); - resolve({ jobIds, groups }); - }; - const flyoutSession = coreStart.overlays.openFlyout( - toMountPoint( - - - - ), - { - 'data-test-subj': 'mlFlyoutJobSelector', - ownFocus: true, - closeButtonAriaLabel: 'jobSelectorFlyout', - } - ); + const flyoutSession = coreStart.overlays.openFlyout( + toMountPoint( + + + + ), + { + 'data-test-subj': 'mlFlyoutJobSelector', + ownFocus: true, + closeButtonAriaLabel: 'jobSelectorFlyout', + } + ); // Close the flyout when user navigates out of the dashboard plugin - // Close the flyout when user navigates out of the dashboard plugin - currentAppId$.pipe(takeUntil(from(flyoutSession.onClose))).subscribe((appId) => { - if (appId !== DashboardConstants.DASHBOARDS_ID) { - flyoutSession.close(); - } - }); + currentAppId$.pipe(takeUntil(from(flyoutSession.onClose))).subscribe((appId) => { + if (appId !== DashboardConstants.DASHBOARDS_ID) { + flyoutSession.close(); + } + }); + } catch (error) { + reject(error); + } }); } diff --git a/x-pack/plugins/ml/server/index.ts b/x-pack/plugins/ml/server/index.ts index fd3744ec734ce..a0c062747bcde 100644 --- a/x-pack/plugins/ml/server/index.ts +++ b/x-pack/plugins/ml/server/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/110898 +/* eslint-disable @kbn/eslint/no_export_all */ + import { PluginInitializerContext } from 'kibana/server'; import { MlServerPlugin } from './plugin'; export type { MlPluginSetup, MlPluginStart } from './plugin'; diff --git a/x-pack/plugins/ml/server/routes/job_audit_messages.ts b/x-pack/plugins/ml/server/routes/job_audit_messages.ts index 4dcaca573fc17..cdef5a9c20dae 100644 --- a/x-pack/plugins/ml/server/routes/job_audit_messages.ts +++ b/x-pack/plugins/ml/server/routes/job_audit_messages.ts @@ -101,7 +101,7 @@ export function jobAuditMessagesRoutes({ router, routeGuard }: RouteInitializati /** * @apiGroup JobAuditMessages * - * @api {put} /api/ml/job_audit_messages/clear_messages/{jobId} Index annotation + * @api {put} /api/ml/job_audit_messages/clear_messages Index annotation * @apiName ClearJobAuditMessages * @apiDescription Clear the job audit messages. * diff --git a/x-pack/plugins/monitoring/public/application/external_config_context.tsx b/x-pack/plugins/monitoring/public/application/external_config_context.tsx new file mode 100644 index 0000000000000..e710032ff1aef --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/external_config_context.tsx @@ -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 { createContext } from 'react'; + +export interface ExternalConfig { + minIntervalSeconds: number; + showLicenseExpiration: boolean; + showCgroupMetricsElasticsearch: boolean; + showCgroupMetricsLogstash: boolean; + renderReactApp: boolean; +} + +export const ExternalConfigContext = createContext({} as ExternalConfig); diff --git a/x-pack/plugins/monitoring/public/application/global_state_context.tsx b/x-pack/plugins/monitoring/public/application/global_state_context.tsx index e6e18e279bbad..dc33316dbd9d9 100644 --- a/x-pack/plugins/monitoring/public/application/global_state_context.tsx +++ b/x-pack/plugins/monitoring/public/application/global_state_context.tsx @@ -11,16 +11,20 @@ import { MonitoringStartPluginDependencies } from '../types'; interface GlobalStateProviderProps { query: MonitoringStartPluginDependencies['data']['query']; toasts: MonitoringStartPluginDependencies['core']['notifications']['toasts']; - children: React.ReactNode; } interface State { cluster_uuid?: string; + ccs?: any; } export const GlobalStateContext = createContext({} as State); -export const GlobalStateProvider = ({ query, toasts, children }: GlobalStateProviderProps) => { +export const GlobalStateProvider: React.FC = ({ + query, + toasts, + children, +}) => { // TODO: remove fakeAngularRootScope and fakeAngularLocation when angular is removed const fakeAngularRootScope: Partial = { $on: ( diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts b/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts index b970d8c84b5b9..e11317fd92bde 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts +++ b/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts @@ -15,7 +15,7 @@ export function useClusters(clusterUuid?: string | null, ccs?: any, codePaths?: const [min] = useState(bounds.min.toISOString()); const [max] = useState(bounds.max.toISOString()); - const [clusters, setClusters] = useState([]); + const [clusters, setClusters] = useState([] as any); const [loaded, setLoaded] = useState(false); let url = '../api/monitoring/v1/clusters'; diff --git a/x-pack/plugins/monitoring/public/application/index.tsx b/x-pack/plugins/monitoring/public/application/index.tsx index ed74d342f7a8f..e15ad995ca161 100644 --- a/x-pack/plugins/monitoring/public/application/index.tsx +++ b/x-pack/plugins/monitoring/public/application/index.tsx @@ -11,17 +11,24 @@ import ReactDOM from 'react-dom'; import { Route, Switch, Redirect, Router } from 'react-router-dom'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { LoadingPage } from './pages/loading_page'; +import { ClusterOverview } from './pages/cluster/overview_page'; import { MonitoringStartPluginDependencies } from '../types'; import { GlobalStateProvider } from './global_state_context'; +import { ExternalConfigContext, ExternalConfig } from './external_config_context'; import { createPreserveQueryHistory } from './preserve_query_history'; import { RouteInit } from './route_init'; +import { MonitoringTimeContainer } from './pages/use_monitoring_time'; export const renderApp = ( core: CoreStart, plugins: MonitoringStartPluginDependencies, - { element }: AppMountParameters + { element }: AppMountParameters, + externalConfig: ExternalConfig ) => { - ReactDOM.render(, element); + ReactDOM.render( + , + element + ); return () => { ReactDOM.unmountComponentAtNode(element); @@ -31,38 +38,48 @@ export const renderApp = ( const MonitoringApp: React.FC<{ core: CoreStart; plugins: MonitoringStartPluginDependencies; -}> = ({ core, plugins }) => { + externalConfig: ExternalConfig; +}> = ({ core, plugins, externalConfig }) => { const history = createPreserveQueryHistory(); return ( - - - - - - - - - - - - + + + + + + + + + + + + + + + + ); }; @@ -75,10 +92,6 @@ const Home: React.FC<{}> = () => { return
Home page (Cluster listing)
; }; -const ClusterOverview: React.FC<{}> = () => { - return
Cluster overview page
; -}; - const License: React.FC<{}> = () => { return
License page
; }; diff --git a/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx new file mode 100644 index 0000000000000..ddc097caea575 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { CODE_PATH_ALL } from '../../../../common/constants'; +import { PageTemplate } from '../page_template'; +import { useClusters } from '../../hooks/use_clusters'; +import { GlobalStateContext } from '../../global_state_context'; +import { TabMenuItem } from '../page_template'; +import { PageLoading } from '../../../components'; +import { Overview } from '../../../components/cluster/overview'; +import { ExternalConfigContext } from '../../external_config_context'; + +const CODE_PATHS = [CODE_PATH_ALL]; + +export const ClusterOverview: React.FC<{}> = () => { + // TODO: check how many requests with useClusters + const state = useContext(GlobalStateContext); + const externalConfig = useContext(ExternalConfigContext); + const { clusters, loaded } = useClusters(state.cluster_uuid, state.ccs, CODE_PATHS); + let tabs: TabMenuItem[] = []; + + const title = i18n.translate('xpack.monitoring.cluster.overviewTitle', { + defaultMessage: 'Overview', + }); + + const pageTitle = i18n.translate('xpack.monitoring.cluster.overview.pageTitle', { + defaultMessage: 'Cluster overview', + }); + + if (loaded) { + tabs = [ + { + id: 'clusterName', + label: clusters[0].cluster_name, + disabled: false, + description: clusters[0].cluster_name, + onClick: () => {}, + testSubj: 'clusterName', + }, + ]; + } + + return ( + + {loaded ? ( + + ) : ( + + )} + + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx index fb766af6c8cbe..f40c2d3ec5e50 100644 --- a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx @@ -5,16 +5,76 @@ * 2.0. */ +import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui'; import React from 'react'; import { useTitle } from '../hooks/use_title'; +import { MonitoringToolbar } from '../../components/shared/toolbar'; +export interface TabMenuItem { + id: string; + label: string; + description: string; + disabled: boolean; + onClick: () => void; + testSubj: string; +} interface PageTemplateProps { title: string; - children: React.ReactNode; + pageTitle?: string; + tabs?: TabMenuItem[]; } -export const PageTemplate = ({ title, children }: PageTemplateProps) => { +export const PageTemplate: React.FC = ({ title, pageTitle, tabs, children }) => { useTitle('', title); - return
{children}
; + return ( +
+ + + + +
{/* HERE GOES THE SETUP BUTTON */}
+
+ + {pageTitle && ( +
+ +

{pageTitle}

+
+
+ )} +
+
+
+ + + + +
+ + {tabs && ( + + {tabs.map((item, idx) => { + return ( + + {item.label} + + ); + })} + + )} +
{children}
+
+ ); }; diff --git a/x-pack/plugins/monitoring/public/application/pages/use_monitoring_time.tsx b/x-pack/plugins/monitoring/public/application/pages/use_monitoring_time.tsx new file mode 100644 index 0000000000000..f54d40ed29a06 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/use_monitoring_time.tsx @@ -0,0 +1,50 @@ +/* + * 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 { useCallback, useState } from 'react'; +import createContainer from 'constate'; + +interface TimeOptions { + from: string; + to: string; + interval: string; +} + +export const DEFAULT_TIMERANGE: TimeOptions = { + from: 'now-1h', + to: 'now', + interval: '>=10s', +}; + +export const useMonitoringTime = () => { + const defaultTimeRange = { + from: 'now-1h', + to: 'now', + interval: DEFAULT_TIMERANGE.interval, + }; + const [refreshInterval, setRefreshInterval] = useState(5000); + const [isPaused, setIsPaused] = useState(false); + const [currentTimerange, setTimeRange] = useState(defaultTimeRange); + + const handleTimeChange = useCallback( + (start: string, end: string) => { + setTimeRange({ ...currentTimerange, from: start, to: end }); + }, + [currentTimerange, setTimeRange] + ); + + return { + currentTimerange, + setTimeRange, + handleTimeChange, + setRefreshInterval, + refreshInterval, + setIsPaused, + isPaused, + }; +}; + +export const MonitoringTimeContainer = createContainer(useMonitoringTime); diff --git a/x-pack/plugins/monitoring/public/components/cluster/overview/index.d.ts b/x-pack/plugins/monitoring/public/components/cluster/overview/index.d.ts new file mode 100644 index 0000000000000..2cfd37e8e27eb --- /dev/null +++ b/x-pack/plugins/monitoring/public/components/cluster/overview/index.d.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const Overview: FunctionComponent; diff --git a/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx b/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx new file mode 100644 index 0000000000000..6e45d4d831ec9 --- /dev/null +++ b/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx @@ -0,0 +1,56 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, OnRefreshChangeProps } from '@elastic/eui'; +import React, { useContext, useCallback } from 'react'; +import { MonitoringTimeContainer } from '../../application/pages/use_monitoring_time'; + +export const MonitoringToolbar = () => { + const { + currentTimerange, + handleTimeChange, + setRefreshInterval, + refreshInterval, + setIsPaused, + isPaused, + } = useContext(MonitoringTimeContainer.Context); + + const onTimeChange = useCallback( + (selectedTime: { start: string; end: string; isInvalid: boolean }) => { + if (selectedTime.isInvalid) { + return; + } + handleTimeChange(selectedTime.start, selectedTime.end); + }, + [handleTimeChange] + ); + + const onRefreshChange = useCallback( + ({ refreshInterval: ri, isPaused: isP }: OnRefreshChangeProps) => { + setRefreshInterval(ri); + setIsPaused(isP); + }, + [setRefreshInterval, setIsPaused] + ); + + return ( + + Setup Button + + {}} + isPaused={isPaused} + refreshInterval={refreshInterval} + onRefreshChange={onRefreshChange} + /> + + + ); +}; diff --git a/x-pack/plugins/monitoring/public/plugin.ts b/x-pack/plugins/monitoring/public/plugin.ts index f1ab86dbad76b..6884dba760fcd 100644 --- a/x-pack/plugins/monitoring/public/plugin.ts +++ b/x-pack/plugins/monitoring/public/plugin.ts @@ -127,7 +127,7 @@ export class MonitoringPlugin const config = Object.fromEntries(externalConfig); if (config.renderReactApp) { const { renderApp } = await import('./application'); - return renderApp(coreStart, pluginsStart, params); + return renderApp(coreStart, pluginsStart, params, config); } else { const monitoringApp = new AngularApp(deps); const removeHistoryListener = params.history.listen((location) => { diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js b/x-pack/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js index 32662ae0efa34..a4645edda73d0 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js @@ -98,27 +98,39 @@ async function getPaginatedThroughputData(pipelines, req, lsIndexPattern, throug const metricSeriesData = Object.values( await Promise.all( pipelines.map((pipeline) => { - return new Promise(async (resolve) => { - const data = await getMetrics( - req, - lsIndexPattern, - [throughputMetric], - [ - { - bool: { - should: [ - { term: { type: 'logstash_stats' } }, - { term: { 'metricset.name': 'stats' } }, - ], + return new Promise(async (resolve, reject) => { + try { + const data = await getMetrics( + req, + lsIndexPattern, + [throughputMetric], + [ + { + bool: { + should: [ + { + term: { + type: 'logstash_stats', + }, + }, + { + term: { + 'metricset.name': 'stats', + }, + }, + ], + }, }, + ], + { + pipeline, }, - ], - { - pipeline, - }, - 2 - ); - resolve(reduceData(pipeline, data)); + 2 + ); + resolve(reduceData(pipeline, data)); + } catch (error) { + reject(error); + } }); }) ) @@ -184,27 +196,38 @@ async function getPipelines(req, lsIndexPattern, pipelines, throughputMetric, no async function getThroughputPipelines(req, lsIndexPattern, pipelines, throughputMetric) { const metricsResponse = await Promise.all( pipelines.map((pipeline) => { - return new Promise(async (resolve) => { - const data = await getMetrics( - req, - lsIndexPattern, - [throughputMetric], - [ - { - bool: { - should: [ - { term: { type: 'logstash_stats' } }, - { term: { 'metricset.name': 'stats' } }, - ], + return new Promise(async (resolve, reject) => { + try { + const data = await getMetrics( + req, + lsIndexPattern, + [throughputMetric], + [ + { + bool: { + should: [ + { + term: { + type: 'logstash_stats', + }, + }, + { + term: { + 'metricset.name': 'stats', + }, + }, + ], + }, }, - }, - ], - { - pipeline, - } - ); - - resolve(reduceData(pipeline, data)); + ], + { + pipeline, + } + ); + resolve(reduceData(pipeline, data)); + } catch (error) { + reject(error); + } }); }) ); diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index ac6389bff8a0b..45fe0258dd142 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -11,6 +11,7 @@ "observability" ], "optionalPlugins": [ + "embeddable", "home", "lens", "licensing", diff --git a/x-pack/plugins/observability/public/components/app/cases/create/flyout.test.tsx b/x-pack/plugins/observability/public/components/app/cases/create/flyout.test.tsx index f92f12c79a56d..dc3db695a3fbf 100644 --- a/x-pack/plugins/observability/public/components/app/cases/create/flyout.test.tsx +++ b/x-pack/plugins/observability/public/components/app/cases/create/flyout.test.tsx @@ -10,16 +10,13 @@ import { mount } from 'enzyme'; import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import { CreateCaseFlyout } from './flyout'; +import { render } from '@testing-library/react'; + +import { useKibana } from '../../../../utils/kibana_react'; +import { CASES_OWNER } from '../constants'; + +jest.mock('../../../../utils/kibana_react'); -jest.mock('../../../../utils/kibana_react', () => ({ - useKibana: () => ({ - services: { - cases: { - getCreateCase: jest.fn(), - }, - }, - }), -})); const onCloseFlyout = jest.fn(); const onSuccess = jest.fn(); const defaultProps = { @@ -28,8 +25,17 @@ const defaultProps = { }; describe('CreateCaseFlyout', () => { + const mockCreateCase = jest.fn(); + beforeEach(() => { jest.resetAllMocks(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: { + getCreateCase: mockCreateCase, + }, + }, + }); }); it('renders', () => { @@ -52,4 +58,22 @@ describe('CreateCaseFlyout', () => { wrapper.find(`[data-test-subj='euiFlyoutCloseButton']`).first().simulate('click'); expect(onCloseFlyout).toBeCalled(); }); + + it('does not show the sync alerts toggle', () => { + render( + + + + ); + + expect(mockCreateCase).toBeCalledTimes(1); + expect(mockCreateCase).toBeCalledWith({ + onCancel: onCloseFlyout, + onSuccess, + afterCaseCreated: undefined, + withSteps: false, + owner: [CASES_OWNER], + disableAlerts: true, + }); + }); }); diff --git a/x-pack/plugins/observability/public/components/app/cases/create/flyout.tsx b/x-pack/plugins/observability/public/components/app/cases/create/flyout.tsx index df29d02e8d830..896bc27a97674 100644 --- a/x-pack/plugins/observability/public/components/app/cases/create/flyout.tsx +++ b/x-pack/plugins/observability/public/components/app/cases/create/flyout.tsx @@ -68,6 +68,7 @@ function CreateCaseFlyoutComponent({ onSuccess, withSteps: false, owner: [CASES_OWNER], + disableAlerts: true, })} diff --git a/x-pack/plugins/observability/public/hooks/use_alert_permission.ts b/x-pack/plugins/observability/public/hooks/use_alert_permission.ts index 2c2837c4bda82..d754c53f23f5c 100644 --- a/x-pack/plugins/observability/public/hooks/use_alert_permission.ts +++ b/x-pack/plugins/observability/public/hooks/use_alert_permission.ts @@ -12,7 +12,7 @@ import { Capabilities } from '../../../../../src/core/types'; export interface UseGetUserAlertsPermissionsProps { crud: boolean; read: boolean; - loading: boolean; + loading?: boolean; featureId: string | null; } @@ -30,9 +30,12 @@ export const getAlertsPermissions = ( } return { - crud: uiCapabilities[featureId].save as boolean, - read: uiCapabilities[featureId].show as boolean, - loading: false, + crud: (featureId === 'apm' + ? uiCapabilities[featureId]['alerting:save'] + : uiCapabilities[featureId].save) as boolean, + read: (featureId === 'apm' + ? uiCapabilities[featureId]['alerting:show'] + : uiCapabilities[featureId].show) as boolean, featureId, }; }; diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index cb390be635e10..380190aa7f223 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/110905 +/* eslint-disable @kbn/eslint/no_export_all */ + import { PluginInitializerContext, PluginInitializer } from 'kibana/public'; import { lazy } from 'react'; import { diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx index 7cb7395acaa8d..2d325b6f3f7c4 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx @@ -14,6 +14,7 @@ import { ALERT_DURATION as ALERT_DURATION_TYPED, ALERT_REASON as ALERT_REASON_TYPED, ALERT_RULE_CONSUMER, + ALERT_RULE_PRODUCER, ALERT_STATUS as ALERT_STATUS_TYPED, ALERT_WORKFLOW_STATUS as ALERT_WORKFLOW_STATUS_TYPED, } from '@kbn/rule-data-utils'; @@ -173,6 +174,9 @@ function ObservabilityActions({ const alertDataConsumer = useMemo(() => get(dataFieldEs, ALERT_RULE_CONSUMER, [''])[0], [ dataFieldEs, ]); + const alertDataProducer = useMemo(() => get(dataFieldEs, ALERT_RULE_PRODUCER, [''])[0], [ + dataFieldEs, + ]); const alert = parseObservabilityAlert(dataFieldEs); const { prepend } = core.http.basePath; @@ -204,7 +208,10 @@ function ObservabilityActions({ } }, [setActionsPopover, refetch]); - const alertPermissions = useGetUserAlertsPermissions(capabilities, alertDataConsumer); + const alertPermissions = useGetUserAlertsPermissions( + capabilities, + alertDataConsumer === 'alerts' ? alertDataProducer : alertDataConsumer + ); const statusActionItems = useStatusBulkActionItems({ eventIds: [eventId], @@ -298,8 +305,11 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { const casePermissions = useGetUserCasesPermissions(); const hasAlertsCrudPermissions = useCallback( - (featureId: string) => { - return getAlertsPermissions(capabilities, featureId).crud; + ({ ruleConsumer, ruleProducer }: { ruleConsumer: string; ruleProducer?: string }) => { + if (ruleConsumer === 'alerts' && ruleProducer) { + return getAlertsPermissions(capabilities, ruleProducer).crud; + } + return getAlertsPermissions(capabilities, ruleConsumer).crud; }, [capabilities] ); diff --git a/x-pack/plugins/observability/public/pages/alerts/example_data.ts b/x-pack/plugins/observability/public/pages/alerts/example_data.ts index 28f8ecec3f34c..1354da592f796 100644 --- a/x-pack/plugins/observability/public/pages/alerts/example_data.ts +++ b/x-pack/plugins/observability/public/pages/alerts/example_data.ts @@ -8,7 +8,7 @@ import { ALERT_DURATION, ALERT_END, - ALERT_ID, + ALERT_INSTANCE_ID, ALERT_SEVERITY, ALERT_RULE_TYPE_ID, ALERT_START, @@ -35,7 +35,7 @@ export const apmAlertResponseExample = [ [ALERT_RULE_UUID]: ['474920d0-93e9-11eb-ac86-0b455460de81'], 'event.action': ['active'], '@timestamp': ['2021-04-12T13:53:49.550Z'], - [ALERT_ID]: ['apm.error_rate_opbeans-java_production'], + [ALERT_INSTANCE_ID]: ['apm.error_rate_opbeans-java_production'], [ALERT_START]: ['2021-04-12T13:50:49.493Z'], [ALERT_RULE_PRODUCER]: ['apm'], 'event.kind': ['state'], @@ -55,7 +55,7 @@ export const apmAlertResponseExample = [ [ALERT_RULE_UUID]: ['474920d0-93e9-11eb-ac86-0b455460de81'], 'event.action': ['close'], '@timestamp': ['2021-04-12T13:49:49.446Z'], - [ALERT_ID]: ['apm.error_rate_opbeans-java_production'], + [ALERT_INSTANCE_ID]: ['apm.error_rate_opbeans-java_production'], [ALERT_START]: ['2021-04-12T13:09:30.441Z'], [ALERT_RULE_PRODUCER]: ['apm'], 'event.kind': ['state'], @@ -116,7 +116,7 @@ export const dynamicIndexPattern = { readFromDocValues: true, }, { - name: ALERT_ID, + name: ALERT_INSTANCE_ID, type: 'string', esTypes: ['keyword'], searchable: true, diff --git a/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx index 0430c750c8862..7e33b61c9b35d 100644 --- a/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx @@ -93,7 +93,7 @@ export const getRenderCellValue = ({ case ALERT_STATUS_RECOVERED: return ( - + {i18n.translate('xpack.observability.alertsTGrid.statusRecoveredDescription', { defaultMessage: 'Recovered', })} diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index e05bf9f311602..eb08e9f3c258d 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/110905 +/* eslint-disable @kbn/eslint/no_export_all */ + import { schema, TypeOf } from '@kbn/config-schema'; import { PluginInitializerContext } from 'src/core/server'; import { ObservabilityPlugin, ObservabilityPluginSetup } from './plugin'; diff --git a/x-pack/plugins/osquery/common/index.ts b/x-pack/plugins/osquery/common/index.ts index fd2c71e290e46..6f1a8c55ad191 100644 --- a/x-pack/plugins/osquery/common/index.ts +++ b/x-pack/plugins/osquery/common/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/110906 +/* eslint-disable @kbn/eslint/no_export_all */ + export * from './constants'; export const PLUGIN_ID = 'osquery'; diff --git a/x-pack/plugins/osquery/public/common/index.ts b/x-pack/plugins/osquery/public/common/index.ts index 6c315f929b9bb..520c2d2da6d39 100644 --- a/x-pack/plugins/osquery/public/common/index.ts +++ b/x-pack/plugins/osquery/public/common/index.ts @@ -5,4 +5,7 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/110906 +/* eslint-disable @kbn/eslint/no_export_all */ + export * from './helpers'; diff --git a/x-pack/plugins/reporting/common/index.ts b/x-pack/plugins/reporting/common/index.ts index a45ef4cf2919d..4bef94999f9d9 100644 --- a/x-pack/plugins/reporting/common/index.ts +++ b/x-pack/plugins/reporting/common/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +// TODO: https://github.com/elastic/kibana/issues/109897 +/* eslint-disable @kbn/eslint/no_export_all */ + export * as constants from './constants'; export { CancellationToken } from './cancellation_token'; export { Poller } from './poller'; diff --git a/x-pack/plugins/reporting/common/poller.ts b/x-pack/plugins/reporting/common/poller.ts index 13ded0576bdf5..3778454c3a4cc 100644 --- a/x-pack/plugins/reporting/common/poller.ts +++ b/x-pack/plugins/reporting/common/poller.ts @@ -8,20 +8,19 @@ import _ from 'lodash'; interface PollerOptions { - functionToPoll: () => Promise; + functionToPoll: () => Promise; pollFrequencyInMillis: number; trailing?: boolean; continuePollingOnError?: boolean; pollFrequencyErrorMultiplier?: number; - successFunction?: (...args: any) => any; - errorFunction?: (error: Error) => any; + successFunction?: (...args: unknown[]) => void; + errorFunction?: (error: Error) => void; } -// @TODO Maybe move to observables someday export class Poller { - private readonly functionToPoll: () => Promise; - private readonly successFunction: (...args: any) => any; - private readonly errorFunction: (error: Error) => any; + private readonly functionToPoll: () => Promise; + private readonly successFunction: (...args: unknown[]) => void; + private readonly errorFunction: (error: Error) => void; private _isRunning: boolean; private _timeoutId: NodeJS.Timeout | null; private pollFrequencyInMillis: number; diff --git a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap index 352c4dddb9f32..e9fd76eb62c79 100644 --- a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap +++ b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap @@ -9876,7 +9876,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` >