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/.github/CODEOWNERS b/.github/CODEOWNERS index 3829121aa5fe9..381fad404ca73 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -439,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/.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/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..26d0c38f72fd7 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 @@ -128,6 +128,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/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..f215655f7f36f 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 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/package.json b/package.json index 836e5336b7b50..20d20d13fa121 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": { @@ -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-eslint-plugin-eslint/index.js b/packages/kbn-eslint-plugin-eslint/index.js index e5a38e5f09529..a7a9c6b5bebdf 100644 --- a/packages/kbn-eslint-plugin-eslint/index.js +++ b/packages/kbn-eslint-plugin-eslint/index.js @@ -12,5 +12,6 @@ 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_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/renovate.json5 b/renovate.json5 index 5ea38e589da4d..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, }, { 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/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 4b1aaf9eb19c1..9ff95c0e04d17 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -204,6 +204,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`, @@ -569,6 +570,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/public.api.md b/src/core/public/public.api.md index 043759378faa3..3a432ae50ea7d 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -592,6 +592,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/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/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/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/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..8bc78956a0919 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 @@ -24,42 +24,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/expressions/common/execution/execution.test.ts b/src/plugins/expressions/common/execution/execution.test.ts index 2e9d4b91908a0..c478977f60764 100644 --- a/src/plugins/expressions/common/execution/execution.test.ts +++ b/src/plugins/expressions/common/execution/execution.test.ts @@ -763,13 +763,15 @@ describe('Execution', () => { }); test('saves duration it took to execute each function', async () => { + const startTime = Date.now(); const execution = createExecution('add val=1 | add val=2 | add val=3', {}, true); execution.start(-1); await execution.result.toPromise(); + const duration = Date.now() - startTime; for (const node of execution.state.get().ast.chain) { expect(typeof node.debug?.duration).toBe('number'); - expect(node.debug?.duration).toBeLessThan(100); + expect(node.debug?.duration).toBeLessThanOrEqual(duration); expect(node.debug?.duration).toBeGreaterThanOrEqual(0); } }); diff --git a/src/plugins/inspector/common/adapters/request/index.ts b/src/plugins/inspector/common/adapters/request/index.ts index 6cee1c0588d73..807f11569ba2c 100644 --- a/src/plugins/inspector/common/adapters/request/index.ts +++ b/src/plugins/inspector/common/adapters/request/index.ts @@ -6,6 +6,6 @@ * Side Public License, v 1. */ -export { RequestStatistic, RequestStatistics, RequestStatus } from './types'; +export { Request, RequestStatistic, RequestStatistics, RequestStatus } from './types'; export { RequestAdapter } from './request_adapter'; export { RequestResponder } from './request_responder'; diff --git a/src/plugins/inspector/common/adapters/request/request_adapter.ts b/src/plugins/inspector/common/adapters/request/request_adapter.ts index 3da528fb3082e..913f16f74b8e2 100644 --- a/src/plugins/inspector/common/adapters/request/request_adapter.ts +++ b/src/plugins/inspector/common/adapters/request/request_adapter.ts @@ -33,14 +33,19 @@ export class RequestAdapter extends EventEmitter { * {@link RequestResponder#error}. * * @param {string} name The name of this request as it should be shown in the UI. - * @param {object} args Additional arguments for the request. + * @param {RequestParams} params Additional arguments for the request. + * @param {number} [startTime] Set an optional start time for the request * @return {RequestResponder} An instance to add information to the request and finish it. */ - public start(name: string, params: RequestParams = {}): RequestResponder { + public start( + name: string, + params: RequestParams = {}, + startTime: number = Date.now() + ): RequestResponder { const req: Request = { ...params, name, - startTime: Date.now(), + startTime, status: RequestStatus.PENDING, id: params.id ?? uuid(), }; diff --git a/src/plugins/inspector/common/index.ts b/src/plugins/inspector/common/index.ts index 224500b6c43aa..e92c9b670475a 100644 --- a/src/plugins/inspector/common/index.ts +++ b/src/plugins/inspector/common/index.ts @@ -8,6 +8,7 @@ export { Adapters, + Request, RequestAdapter, RequestStatistic, RequestStatistics, diff --git a/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx b/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx index 53503b197567e..39e345568a298 100644 --- a/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx +++ b/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx @@ -19,13 +19,18 @@ describe('MountPointPortal', () => { let dom: ReactWrapper; const refresh = () => { - new Promise(async (resolve) => { - if (dom) { - act(() => { - dom.update(); - }); + new Promise(async (resolve, reject) => { + try { + if (dom) { + act(() => { + dom.update(); + }); + } + + setImmediate(() => resolve(dom)); // flushes any pending promises + } catch (error) { + reject(error); } - setImmediate(() => resolve(dom)); // flushes any pending promises }); }; diff --git a/src/plugins/maps_ems/public/lazy_load_bundle/get_service_settings.ts b/src/plugins/maps_ems/public/lazy_load_bundle/get_service_settings.ts index 6e32ff5d4e41e..8eafada176e7a 100644 --- a/src/plugins/maps_ems/public/lazy_load_bundle/get_service_settings.ts +++ b/src/plugins/maps_ems/public/lazy_load_bundle/get_service_settings.ts @@ -16,10 +16,14 @@ export async function getServiceSettings(): Promise { return loadPromise; } - loadPromise = new Promise(async (resolve) => { - const { ServiceSettings } = await import('./lazy'); - const config = getMapsEmsConfig(); - resolve(new ServiceSettings(config, config.tilemap)); + loadPromise = new Promise(async (resolve, reject) => { + try { + const { ServiceSettings } = await import('./lazy'); + const config = getMapsEmsConfig(); + resolve(new ServiceSettings(config, config.tilemap)); + } catch (error) { + reject(error); + } }); return loadPromise; } diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 558739a44dd41..45b9b4c7a885b 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -109,13 +109,18 @@ describe('TopNavMenu', () => { let dom: ReactWrapper; const refresh = () => { - new Promise(async (resolve) => { - if (dom) { - act(() => { - dom.update(); - }); + new Promise(async (resolve, reject) => { + try { + if (dom) { + act(() => { + dom.update(); + }); + } + + setImmediate(() => resolve(dom)); // flushes any pending promises + } catch (error) { + reject(error); } - setImmediate(() => resolve(dom)); // flushes any pending promises }); }; diff --git a/src/plugins/vis_type_table/public/legacy/vis_controller.ts b/src/plugins/vis_type_table/public/legacy/vis_controller.ts index ec198aa96f1f9..a9cb22a056913 100644 --- a/src/plugins/vis_type_table/public/legacy/vis_controller.ts +++ b/src/plugins/vis_type_table/public/legacy/vis_controller.ts @@ -71,35 +71,42 @@ export function getTableVisualizationControllerClass( await this.initLocalAngular(); return new Promise(async (resolve, reject) => { - if (!this.$rootScope) { - const $injector = this.getInjector(); - this.$rootScope = $injector.get('$rootScope'); - this.$compile = $injector.get('$compile'); - } - const updateScope = () => { - if (!this.$scope) { - return; + try { + if (!this.$rootScope) { + const $injector = this.getInjector(); + this.$rootScope = $injector.get('$rootScope'); + this.$compile = $injector.get('$compile'); } - this.$scope.visState = { params: visParams, title: visParams.title }; - this.$scope.esResponse = esResponse; - - this.$scope.visParams = visParams; - this.$scope.renderComplete = resolve; - this.$scope.renderFailed = reject; - this.$scope.resize = Date.now(); - this.$scope.$apply(); - }; - - if (!this.$scope && this.$compile) { - this.$scope = this.$rootScope.$new(); - this.$scope.uiState = handlers.uiState; - this.$scope.filter = handlers.event; - updateScope(); - this.el.find('div').append(this.$compile(tableVisTemplate)(this.$scope)); - this.$scope.$apply(); - } else { - updateScope(); + const updateScope = () => { + if (!this.$scope) { + return; + } + + this.$scope.visState = { + params: visParams, + title: visParams.title, + }; + this.$scope.esResponse = esResponse; + this.$scope.visParams = visParams; + this.$scope.renderComplete = resolve; + this.$scope.renderFailed = reject; + this.$scope.resize = Date.now(); + this.$scope.$apply(); + }; + + if (!this.$scope && this.$compile) { + this.$scope = this.$rootScope.$new(); + this.$scope.uiState = handlers.uiState; + this.$scope.filter = handlers.event; + updateScope(); + this.el.find('div').append(this.$compile(tableVisTemplate)(this.$scope)); + this.$scope.$apply(); + } else { + updateScope(); + } + } catch (error) { + reject(error); } }); } diff --git a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx index 56f9025a6bd0b..4701d07ab83e6 100644 --- a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx +++ b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx @@ -124,16 +124,25 @@ export class VisLegend extends PureComponent { }; setFilterableLabels = (items: LegendItem[]): Promise => - new Promise(async (resolve) => { - const filterableLabels = new Set(); - items.forEach(async (item) => { - const canFilter = await this.canFilter(item); - if (canFilter) { - filterableLabels.add(item.label); - } - }); - - this.setState({ filterableLabels }, resolve); + new Promise(async (resolve, reject) => { + try { + const filterableLabels = new Set(); + items.forEach(async (item) => { + const canFilter = await this.canFilter(item); + + if (canFilter) { + filterableLabels.add(item.label); + } + }); + this.setState( + { + filterableLabels, + }, + resolve + ); + } catch (error) { + reject(error); + } }); setLabels = (data: any, type: string) => { diff --git a/src/setup_node_env/exit_on_warning.js b/src/setup_node_env/exit_on_warning.js index 5fbee02708083..e9c96f2c49bb4 100644 --- a/src/setup_node_env/exit_on_warning.js +++ b/src/setup_node_env/exit_on_warning.js @@ -34,8 +34,7 @@ var IGNORE_WARNINGS = [ // that the security features are blocking such check. // Such emit is causing Node.js to crash unless we explicitly catch it. // We need to discard that warning - message: - 'The client is unable to verify that the server is Elasticsearch due to security privileges on the server side. Some functionality may not be compatible if the server is running an unsupported product.', + name: 'ProductNotSupportedSecurityError', }, ]; diff --git a/x-pack/plugins/actions/server/saved_objects/action_task_params_migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/action_task_params_migrations.test.ts index ceea9f3cff18f..6d7fd940612f3 100644 --- a/x-pack/plugins/actions/server/saved_objects/action_task_params_migrations.test.ts +++ b/x-pack/plugins/actions/server/saved_objects/action_task_params_migrations.test.ts @@ -356,6 +356,14 @@ describe('successful migrations', () => { }); }); }); + + describe('8.0.0', () => { + test('no op migration for rules SO', () => { + const migration800 = getActionTaskParamsMigrations(encryptedSavedObjectsSetup, [])['8.0.0']; + const actionTaskParam = getMockData(); + expect(migration800(actionTaskParam, context)).toEqual(actionTaskParam); + }); + }); }); describe('handles errors during migrations', () => { diff --git a/x-pack/plugins/actions/server/saved_objects/action_task_params_migrations.ts b/x-pack/plugins/actions/server/saved_objects/action_task_params_migrations.ts index 3612642160443..ceb82146a03eb 100644 --- a/x-pack/plugins/actions/server/saved_objects/action_task_params_migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/action_task_params_migrations.ts @@ -48,8 +48,17 @@ export function getActionTaskParamsMigrations( pipeMigrations(getUseSavedObjectReferencesFn(preconfiguredActions)) ); + const migrationActionsTaskParams800 = createEsoMigration( + encryptedSavedObjects, + ( + doc: SavedObjectUnsanitizedDoc + ): doc is SavedObjectUnsanitizedDoc => true, + (doc) => doc // no-op + ); + return { '7.16.0': executeMigrationWithErrorHandling(migrationActionTaskParamsSixteen, '7.16.0'), + '8.0.0': executeMigrationWithErrorHandling(migrationActionsTaskParams800, '8.0.0'), }; } diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts index bc0e59279abc1..7dc1426c13a4b 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts @@ -118,6 +118,14 @@ describe('successful migrations', () => { }); }); }); + + describe('8.0.0', () => { + test('no op migration for rules SO', () => { + const migration800 = getActionsMigrations(encryptedSavedObjectsSetup)['8.0.0']; + const action = getMockData({}); + expect(migration800(action, context)).toEqual(action); + }); + }); }); describe('handles errors during migrations', () => { diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts index a72565e00ef7b..7857a9e1f833f 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts @@ -62,10 +62,18 @@ export function getActionsMigrations( pipeMigrations(addisMissingSecretsField) ); + const migrationActions800 = createEsoMigration( + encryptedSavedObjects, + (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => + true, + (doc) => doc // no-op + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'), '7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'), + '8.0.0': executeMigrationWithErrorHandling(migrationActions800, '8.0.0'), }; } diff --git a/x-pack/plugins/actions/server/saved_objects/index.ts b/x-pack/plugins/actions/server/saved_objects/index.ts index 71ec92645b249..14b425d20af13 100644 --- a/x-pack/plugins/actions/server/saved_objects/index.ts +++ b/x-pack/plugins/actions/server/saved_objects/index.ts @@ -35,7 +35,8 @@ export function setupSavedObjects( savedObjects.registerType({ name: ACTION_SAVED_OBJECT_TYPE, hidden: true, - namespaceType: 'single', + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', mappings: mappings.action as SavedObjectsTypeMappingDefinition, migrations: getActionsMigrations(encryptedSavedObjects), management: { @@ -71,7 +72,8 @@ export function setupSavedObjects( savedObjects.registerType({ name: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, hidden: true, - namespaceType: 'single', + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', mappings: mappings.action_task_params as SavedObjectsTypeMappingDefinition, migrations: getActionTaskParamsMigrations(encryptedSavedObjects, preconfiguredActions), excludeOnUpgrade: async ({ readonlyEsClient }) => { diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index b1d56a364a3dd..f1afba147a2f7 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -53,7 +53,8 @@ export function setupSavedObjects( savedObjects.registerType({ name: 'alert', hidden: true, - namespaceType: 'single', + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', migrations: getMigrations(encryptedSavedObjects, isPreconfigured), mappings: mappings.alert as SavedObjectsTypeMappingDefinition, management: { diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index e460167b40d23..5e850ad3226f8 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -1700,6 +1700,14 @@ describe('successful migrations', () => { }); }); }); + + describe('8.0.0', () => { + test('no op migration for rules SO', () => { + const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const alert = getMockData({}, true); + expect(migration800(alert, migrationContext)).toEqual(alert); + }); + }); }); describe('handles errors during migrations', () => { diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index c0af554cd7a44..287636c69bb75 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -106,6 +106,12 @@ export function getMigrations( pipeMigrations(setLegacyId, getRemovePreconfiguredConnectorsFromReferencesFn(isPreconfigured)) ); + const migrationRules800 = createEsoMigration( + encryptedSavedObjects, + (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, + (doc) => doc // no-op + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), @@ -114,6 +120,7 @@ export function getMigrations( '7.14.1': executeMigrationWithErrorHandling(migrationSecurityRules714, '7.14.1'), '7.15.0': executeMigrationWithErrorHandling(migrationSecurityRules715, '7.15.0'), '7.16.0': executeMigrationWithErrorHandling(migrateRules716, '7.16.0'), + '8.0.0': executeMigrationWithErrorHandling(migrationRules800, '8.0.0'), }; } diff --git a/x-pack/plugins/apm/common/backends.ts b/x-pack/plugins/apm/common/backends.ts index 35a52cf3778f1..53418fe7f9b62 100644 --- a/x-pack/plugins/apm/common/backends.ts +++ b/x-pack/plugins/apm/common/backends.ts @@ -14,9 +14,9 @@ import { import { environmentQuery } from './utils/environment_query'; export const kueryBarPlaceholder = i18n.translate( - 'xpack.apm.backends.kueryBarPlaceholder', + 'xpack.apm.dependencies.kueryBarPlaceholder', { - defaultMessage: `Search backend metrics (e.g. span.destination.service.resource:elasticsearch)`, + defaultMessage: `Search dependency metrics (e.g. span.destination.service.resource:elasticsearch)`, } ); diff --git a/x-pack/plugins/apm/ftr_e2e/config.ts b/x-pack/plugins/apm/ftr_e2e/config.ts index 5f919fb7f075d..12cc8845264c2 100644 --- a/x-pack/plugins/apm/ftr_e2e/config.ts +++ b/x-pack/plugins/apm/ftr_e2e/config.ts @@ -35,6 +35,7 @@ async function config({ readConfigFile }: FtrConfigProviderContext) { ...xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs'), '--home.disableWelcomeScreen=true', '--csp.strict=false', + '--csp.warnLegacyBrowsers=false', // define custom kibana server args here `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, ], diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/power_user/settings/custom_links.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/power_user/settings/custom_links.spec.ts new file mode 100644 index 0000000000000..eeb46db04b9d4 --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/power_user/settings/custom_links.spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const basePath = '/app/apm/settings/customize-ui'; + +describe('Custom links', () => { + beforeEach(() => { + cy.loginAsPowerUser(); + }); + + it('shows empty message and create button', () => { + cy.visit(basePath); + cy.contains('No links found'); + cy.contains('Create custom link'); + }); + + it('creates custom link', () => { + cy.visit(basePath); + const emptyPrompt = cy.get('[data-test-subj="customLinksEmptyPrompt"]'); + cy.contains('Create custom link').click(); + cy.contains('Create link'); + cy.contains('Save').should('be.disabled'); + cy.get('input[name="label"]').type('foo'); + cy.get('input[name="url"]').type('https://foo.com'); + cy.contains('Save').should('not.be.disabled'); + cy.contains('Save').click(); + emptyPrompt.should('not.exist'); + cy.contains('foo'); + cy.contains('https://foo.com'); + cy.get('[data-test-subj="editCustomLink"]').click(); + cy.contains('Delete').click(); + }); +}); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts index de8969cbecdeb..b90ad12b46025 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts @@ -74,6 +74,10 @@ describe('Service Overview', () => { cy.contains('Service Map'); // Waits until the agent request is finished to check the tab. cy.wait('@agentRequest'); - cy.contains('Dependencies').should('not.exist'); + cy.get('.euiTabs .euiTab__content').then((elements) => { + elements.map((index, element) => { + expect(element.innerText).to.not.equal('Dependencies'); + }); + }); }); }); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.ts b/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.ts index 31eab9507ef5e..93dbe4ba51226 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.ts @@ -11,8 +11,8 @@ Cypress.Commands.add('loginAsReadOnlyUser', () => { cy.loginAs({ username: 'apm_read_user', password: 'changeme' }); }); -Cypress.Commands.add('loginAsSuperUser', () => { - cy.loginAs({ username: 'elastic', password: 'changeme' }); +Cypress.Commands.add('loginAsPowerUser', () => { + cy.loginAs({ username: 'apm_power_user', password: 'changeme' }); }); Cypress.Commands.add( diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/support/types.d.ts b/x-pack/plugins/apm/ftr_e2e/cypress/support/types.d.ts index b47e664e0a0f8..2d9ef090eef65 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/support/types.d.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/support/types.d.ts @@ -8,7 +8,7 @@ declare namespace Cypress { interface Chainable { loginAsReadOnlyUser(): void; - loginAsSuperUser(): void; + loginAsPowerUser(): void; loginAs(params: { username: string; password: string }): void; changeTimeRange(value: string): void; expectAPIsToHaveBeenCalledWith(params: { diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 40e724e306bc0..5bc365e35cb2f 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -12,6 +12,7 @@ "embeddable", "features", "infra", + "inspector", "licensing", "observability", "ruleRegistry", diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index a6b0dc61a3260..feb1ff372dc96 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -48,6 +48,7 @@ export const renderApp = ({ core: coreStart, plugins: pluginsSetup, data: pluginsStart.data, + inspector: pluginsStart.inspector, observability: pluginsStart.observability, observabilityRuleTypeRegistry, }; diff --git a/x-pack/plugins/apm/public/application/uxApp.tsx b/x-pack/plugins/apm/public/application/uxApp.tsx index 1b36008e5c353..ddcccf45ccab5 100644 --- a/x-pack/plugins/apm/public/application/uxApp.tsx +++ b/x-pack/plugins/apm/public/application/uxApp.tsx @@ -91,7 +91,7 @@ export function UXAppRoot({ core, deps, config, - corePlugins: { embeddable, maps, observability, data }, + corePlugins: { embeddable, inspector, maps, observability, data }, observabilityRuleTypeRegistry, }: { appMountParameters: AppMountParameters; @@ -108,6 +108,7 @@ export function UXAppRoot({ appMountParameters, config, core, + inspector, plugins, observability, observabilityRuleTypeRegistry, diff --git a/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/EmptyPrompt.tsx b/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/EmptyPrompt.tsx index 9d6a3eef3f7eb..498e17b9c359d 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/EmptyPrompt.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/EmptyPrompt.tsx @@ -17,6 +17,7 @@ export function EmptyPrompt({ }) { return ( )} -
- - - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.flyout.title', + + + + +

+ {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.title', + { + defaultMessage: 'Create link', + } + )} +

+
+
+ + +

+ {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.label', + { + defaultMessage: + 'Links will be available in the context of transaction details throughout the APM app. You can create an unlimited number of links. You can refer to dynamic variables by using any of the transaction metadata to fill in your URLs. More information, including examples, are available in the', + } + )}{' '} + - - - - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.flyout.label', - { - defaultMessage: - 'Links will be available in the context of transaction details throughout the APM app. You can create an unlimited number of links. You can refer to dynamic variables by using any of the transaction metadata to fill in your URLs. More information, including examples, are available in the', - } - )}{' '} - -

-
+ /> +

+ - + - + - + - + - + - -
+ + - -
- - + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/custom_link_table.tsx b/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/custom_link_table.tsx index 4a242bb661e3a..86a7a8742eaea 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/custom_link_table.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/custom_link_table.tsx @@ -79,6 +79,7 @@ export function CustomLinkTable({ items = [], onCustomLinkSelected }: Props) { icon: 'pencil', color: 'primary', type: 'icon', + 'data-test-subj': 'editCustomLink', onClick: (customLink: CustomLink) => { onCustomLinkSelected(customLink); }, diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx index 1adb41acab70a..2c9ec0a232974 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx @@ -18,7 +18,7 @@ import { useApmParams } from '../../../hooks/use_apm_params'; import { useApmRouter } from '../../../hooks/use_apm_router'; import { SearchBar } from '../../shared/search_bar'; import { BackendLatencyChart } from './backend_latency_chart'; -import { BackendInventoryTitle } from '../../routing/home'; +import { DependenciesInventoryTitle } from '../../routing/home'; import { BackendDetailDependenciesTable } from './backend_detail_dependencies_table'; import { BackendThroughputChart } from './backend_throughput_chart'; import { BackendFailedTransactionRateChart } from './backend_error_rate_chart'; @@ -39,7 +39,7 @@ export function BackendDetailOverview() { useBreadcrumb([ { - title: BackendInventoryTitle, + title: DependenciesInventoryTitle, href: apmRouter.link('/backends', { query: { rangeFrom, rangeTo, environment, kuery }, }), diff --git a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx index 7ccf3f166fc65..ea135104982e5 100644 --- a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx @@ -98,9 +98,9 @@ export function BackendInventoryDependenciesTable() { dependencies={dependencies} title={null} nameColumnTitle={i18n.translate( - 'xpack.apm.backendInventory.dependenciesTableColumnBackend', + 'xpack.apm.backendInventory.dependencyTableColumn', { - defaultMessage: 'Backend', + defaultMessage: 'Dependency', } )} status={status} diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/backend_contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/backend_contents.tsx index 0a42dbab9a452..9bc30ee67d2c7 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/backend_contents.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/backend_contents.tsx @@ -85,8 +85,8 @@ export function BackendContents({ }); }} > - {i18n.translate('xpack.apm.serviceMap.backendDetailsButtonText', { - defaultMessage: 'Backend Details', + {i18n.translate('xpack.apm.serviceMap.dependencyDetailsButtonText', { + defaultMessage: 'Dependency Details', })} diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/popover.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/popover.test.tsx index 9678258c4740c..5bec70b9eb841 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/popover.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/popover.test.tsx @@ -14,12 +14,12 @@ const { Backend, ExternalsList, Resource, Service } = composeStories(stories); describe('Popover', () => { describe('with backend data', () => { - it('renders a backend link', async () => { + it('renders a dependency link', async () => { render(); await waitFor(() => { expect( - screen.getByRole('link', { name: /backend details/i }) + screen.getByRole('link', { name: /Dependency Details/i }) ).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index 0d0842582335b..08f29d7727cda 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -125,13 +125,13 @@ export function ServiceOverviewDependenciesTable({ title={i18n.translate( 'xpack.apm.serviceOverview.dependenciesTableTitle', { - defaultMessage: 'Downstream services and backends', + defaultMessage: 'Dependencies', } )} nameColumnTitle={i18n.translate( - 'xpack.apm.serviceOverview.dependenciesTableColumnBackend', + 'xpack.apm.serviceOverview.dependenciesTableColumn', { - defaultMessage: 'Backend', + defaultMessage: 'Dependency', } )} status={status} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx index 9b8706fe11035..6751e76cfa335 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx @@ -37,10 +37,12 @@ export function ServiceOverviewThroughputChart({ height, environment, kuery, + transactionName, }: { height?: number; environment: string; kuery: string; + transactionName?: string; }) { const theme = useTheme(); @@ -80,6 +82,7 @@ export function ServiceOverviewThroughputChart({ transactionType, comparisonStart, comparisonEnd, + transactionName, }, }, }); @@ -94,6 +97,7 @@ export function ServiceOverviewThroughputChart({ transactionType, comparisonStart, comparisonEnd, + transactionName, ] ); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx index c4ecc71941b8c..9da1ee25246dd 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx @@ -50,6 +50,7 @@ export function TransactionDetails() { environment={query.environment} start={start} end={end} + transactionName={transactionName} /> diff --git a/x-pack/plugins/apm/public/components/routing/app_root.tsx b/x-pack/plugins/apm/public/components/routing/app_root.tsx index 498d489691e77..c32828eca2f69 100644 --- a/x-pack/plugins/apm/public/components/routing/app_root.tsx +++ b/x-pack/plugins/apm/public/components/routing/app_root.tsx @@ -26,6 +26,7 @@ import { } from '../../context/apm_plugin/apm_plugin_context'; import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; import { BreadcrumbsContextProvider } from '../../context/breadcrumbs/context'; +import { InspectorContextProvider } from '../../context/inspector/inspector_context'; import { LicenseProvider } from '../../context/license/license_context'; import { TimeRangeIdContextProvider } from '../../context/time_range_id/time_range_id_context'; import { UrlParamsProvider } from '../../context/url_params_context/url_params_context'; @@ -62,12 +63,14 @@ export function ApmAppRoot({ - - + + + - - - + + + + diff --git a/x-pack/plugins/apm/public/components/routing/home/index.tsx b/x-pack/plugins/apm/public/components/routing/home/index.tsx index d1304e192ddce..1430f5d8e4756 100644 --- a/x-pack/plugins/apm/public/components/routing/home/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/home/index.tsx @@ -45,10 +45,10 @@ export const ServiceInventoryTitle = i18n.translate( } ); -export const BackendInventoryTitle = i18n.translate( - 'xpack.apm.views.backendInventory.title', +export const DependenciesInventoryTitle = i18n.translate( + 'xpack.apm.views.dependenciesInventory.title', { - defaultMessage: 'Backends', + defaultMessage: 'Dependencies', } ); @@ -114,7 +114,7 @@ export const home = { }, page({ path: '/', - title: BackendInventoryTitle, + title: DependenciesInventoryTitle, element: , }), ], diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx index 7d000a29dcbec..633d03ce8e1df 100644 --- a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx @@ -14,6 +14,7 @@ import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_ import { AlertingPopoverAndFlyout } from './alerting_popover_flyout'; import { AnomalyDetectionSetupLink } from './anomaly_detection_setup_link'; import { useServiceName } from '../../../hooks/use_service_name'; +import { InspectorHeaderLink } from './inspector_header_link'; export function ApmHeaderActionMenu() { const { core, plugins } = useApmPluginContext(); @@ -65,6 +66,7 @@ export function ApmHeaderActionMenu() { defaultMessage: 'Add data', })} + ); } diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/inspector_header_link.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/inspector_header_link.tsx new file mode 100644 index 0000000000000..7f1848e76d28a --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/inspector_header_link.tsx @@ -0,0 +1,39 @@ +/* + * 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 { EuiHeaderLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { enableInspectEsQueries } from '../../../../../observability/public'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { useInspectorContext } from '../../../context/inspector/use_inspector_context'; + +export function InspectorHeaderLink() { + const { inspector } = useApmPluginContext(); + const { inspectorAdapters } = useInspectorContext(); + const { + services: { uiSettings }, + } = useKibana(); + const isInspectorEnabled = uiSettings?.get(enableInspectEsQueries); + + const inspect = () => { + inspector.open(inspectorAdapters); + }; + + if (!isInspectorEnabled) { + return null; + } + + return ( + + {i18n.translate('xpack.apm.inspectButtonText', { + defaultMessage: 'Inspect', + })} + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index 6e2ed04776e1c..4fdce0dfa705e 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -19,11 +19,13 @@ export function TransactionCharts({ environment, start, end, + transactionName, }: { kuery: string; environment: string; start: string; end: string; + transactionName?: string; }) { return ( <> @@ -44,6 +46,7 @@ export function TransactionCharts({ diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index 6e5896c9b5e4b..55e19e547b282 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -7,19 +7,12 @@ import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types'; import { - EuiCallOut, EuiFlexGroup, + EuiFlexGroupProps, EuiFlexItem, - EuiLink, EuiSpacer, - EuiFlexGroupProps, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { enableInspectEsQueries } from '../../../../observability/public'; -import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; -import { useKibanaUrl } from '../../hooks/useKibanaUrl'; import { useBreakPoints } from '../../hooks/use_break_points'; import { DatePicker } from './DatePicker'; import { KueryBar } from './kuery_bar'; @@ -35,52 +28,6 @@ interface Props { kueryBarBoolFilter?: QueryDslQueryContainer[]; } -function DebugQueryCallout() { - const { uiSettings } = useApmPluginContext().core; - const advancedSettingsUrl = useKibanaUrl('/app/management/kibana/settings', { - query: { - query: 'category:(observability)', - }, - }); - - if (!uiSettings.get(enableInspectEsQueries)) { - return null; - } - - return ( - - - - - {i18n.translate( - 'xpack.apm.searchBar.inspectEsQueriesEnabled.callout.description.advancedSettings', - { defaultMessage: 'Advanced Settings' } - )} - - ), - }} - /> - - - - ); -} - export function SearchBar({ hidden = false, showKueryBar = true, @@ -100,7 +47,6 @@ export function SearchBar({ return ( <> - (result: FetcherResult) => void; + inspectorAdapters: { requests: RequestAdapter }; +} + +const value: InspectorContextValue = { + addInspectorRequest: () => {}, + inspectorAdapters: { requests: new RequestAdapter() }, +}; + +export const InspectorContext = createContext(value); + +export function InspectorContextProvider({ + children, +}: { + children: ReactNode; +}) { + const history = useHistory(); + const { inspectorAdapters } = value; + + function addInspectorRequest( + result: FetcherResult<{ + mainStatisticsData?: { _inspect?: InspectResponse }; + _inspect?: InspectResponse; + }> + ) { + const operations = + result.data?._inspect ?? result.data?.mainStatisticsData?._inspect ?? []; + + operations.forEach((operation) => { + if (operation.response) { + const { id, name } = operation; + const requestParams = { id, name }; + + const requestResponder = inspectorAdapters.requests.start( + id, + requestParams, + operation.startTime + ); + + requestResponder.json(operation.json as object); + + if (operation.stats) { + requestResponder.stats(operation.stats); + } + + requestResponder.finish(operation.status, operation.response); + } + }); + } + + useEffect(() => { + const unregisterCallback = history.listen((newLocation) => { + if (history.location.pathname !== newLocation.pathname) { + inspectorAdapters.requests.reset(); + } + }); + + return () => { + unregisterCallback(); + }; + }, [history, inspectorAdapters]); + + return ( + + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/context/inspector/use_inspector_context.tsx b/x-pack/plugins/apm/public/context/inspector/use_inspector_context.tsx new file mode 100644 index 0000000000000..a60ed6c8c72e1 --- /dev/null +++ b/x-pack/plugins/apm/public/context/inspector/use_inspector_context.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useContext } from 'react'; +import { InspectorContext } from './inspector_context'; + +export function useInspectorContext() { + return useContext(InspectorContext); +} diff --git a/x-pack/plugins/apm/public/hooks/use_fetcher.tsx b/x-pack/plugins/apm/public/hooks/use_fetcher.tsx index df7487290848a..d5a10a6e91539 100644 --- a/x-pack/plugins/apm/public/hooks/use_fetcher.tsx +++ b/x-pack/plugins/apm/public/hooks/use_fetcher.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import React, { useEffect, useMemo, useState } from 'react'; import { IHttpFetchError } from 'src/core/public'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { useInspectorContext } from '../context/inspector/use_inspector_context'; import { useTimeRangeId } from '../context/time_range_id/use_time_range_id'; import { AutoAbortedAPMClient, @@ -77,6 +78,7 @@ export function useFetcher( }); const [counter, setCounter] = useState(0); const { timeRangeId } = useTimeRangeId(); + const { addInspectorRequest } = useInspectorContext(); useEffect(() => { let controller: AbortController = new AbortController(); @@ -165,6 +167,14 @@ export function useFetcher( /* eslint-enable react-hooks/exhaustive-deps */ ]); + useEffect(() => { + if (result.error) { + addInspectorRequest({ ...result, data: result.error.body?.attributes }); + } else { + addInspectorRequest(result); + } + }, [addInspectorRequest, result]); + return useMemo(() => { return { ...result, diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index c884f228c85d2..da2ea0ba8ae5c 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -23,13 +23,14 @@ import type { DataPublicPluginStart, } from '../../../../src/plugins/data/public'; import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; -import type { FleetStart } from '../../fleet/public'; import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { Start as InspectorPluginStart } from '../../../../src/plugins/inspector/public'; import type { PluginSetupContract as AlertingPluginPublicSetup, PluginStartContract as AlertingPluginPublicStart, } from '../../alerting/public'; import type { FeaturesPluginSetup } from '../../features/public'; +import type { FleetStart } from '../../fleet/public'; import type { LicensingPluginSetup } from '../../licensing/public'; import type { MapsStartApi } from '../../maps/public'; import type { MlPluginSetup, MlPluginStart } from '../../ml/public'; @@ -45,15 +46,14 @@ import type { TriggersAndActionsUIPublicPluginStart, } from '../../triggers_actions_ui/public'; import { registerApmAlerts } from './components/alerting/register_apm_alerts'; -import { featureCatalogueEntry } from './featureCatalogueEntry'; import { getApmEnrollmentFlyoutData, LazyApmCustomAssetsExtension, } from './components/fleet_integration'; +import { getLazyApmAgentsTabExtension } from './components/fleet_integration/lazy_apm_agents_tab_extension'; import { getLazyAPMPolicyCreateExtension } from './components/fleet_integration/lazy_apm_policy_create_extension'; import { getLazyAPMPolicyEditExtension } from './components/fleet_integration/lazy_apm_policy_edit_extension'; -import { getLazyApmAgentsTabExtension } from './components/fleet_integration/lazy_apm_agents_tab_extension'; - +import { featureCatalogueEntry } from './featureCatalogueEntry'; export type ApmPluginSetup = ReturnType; export type ApmPluginStart = void; @@ -74,6 +74,7 @@ export interface ApmPluginStartDeps { data: DataPublicPluginStart; embeddable: EmbeddableStart; home: void; + inspector: InspectorPluginStart; licensing: void; maps?: MapsStartApi; ml?: MlPluginStart; @@ -92,9 +93,12 @@ const serviceMapTitle = i18n.translate('xpack.apm.navigation.serviceMapTitle', { defaultMessage: 'Service Map', }); -const backendsTitle = i18n.translate('xpack.apm.navigation.backendsTitle', { - defaultMessage: 'Backends', -}); +const dependenciesTitle = i18n.translate( + 'xpack.apm.navigation.dependenciesTitle', + { + defaultMessage: 'Dependencies', + } +); export class ApmPlugin implements Plugin { constructor( @@ -125,7 +129,7 @@ export class ApmPlugin implements Plugin { { label: servicesTitle, app: 'apm', path: '/services' }, { label: tracesTitle, app: 'apm', path: '/traces' }, { - label: backendsTitle, + label: dependenciesTitle, app: 'apm', path: '/backends', isNewFeature: true, @@ -269,7 +273,7 @@ export class ApmPlugin implements Plugin { { id: 'services', title: servicesTitle, path: '/services' }, { id: 'traces', title: tracesTitle, path: '/traces' }, { id: 'service-map', title: serviceMapTitle, path: '/service-map' }, - { id: 'backends', title: backendsTitle, path: '/backends' }, + { id: 'backends', title: dependenciesTitle, path: '/backends' }, ], async mount(appMountParameters: AppMountParameters) { diff --git a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts index 217d7e050369d..35dbca1b0c955 100644 --- a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts @@ -25,10 +25,10 @@ import { FetchOptions } from '../../../common/fetch_options'; import { callApi } from './callApi'; import type { APMServerRouteRepository, - InspectResponse, APMRouteHandlerResources, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../server'; +import { InspectResponse } from '../../../typings/common'; export type APMClientOptions = Omit< FetchOptions, diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index b6dd22c528e99..5b97173601950 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -131,6 +131,6 @@ export { APM_SERVER_FEATURE_ID } from '../common/alert_types'; export { APMPlugin } from './plugin'; export { APMPluginSetup } from './types'; export { APMServerRouteRepository } from './routes/get_global_apm_server_route_repository'; -export { InspectResponse, APMRouteHandlerResources } from './routes/typings'; +export { APMRouteHandlerResources } from './routes/typings'; export type { ProcessorEvent } from '../common/processor_event'; diff --git a/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts index b339c1f1f0be9..afe3a95d79023 100644 --- a/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts +++ b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts @@ -16,6 +16,7 @@ interface GetApmPackagePolicyDefinitionOptions { export function getApmPackagePolicyDefinition( options: GetApmPackagePolicyDefinitionOptions ) { + const { apmServerSchema, cloudPluginSetup } = options; return { name: 'Elastic APM', namespace: 'default', @@ -27,7 +28,10 @@ export function getApmPackagePolicyDefinition( type: 'apm', enabled: true, streams: [], - vars: getApmPackageInputVars(options), + vars: getApmPackageInputVars({ + cloudPluginSetup, + apmServerSchema: preprocessLegacyFields({ apmServerSchema }), + }), }, ], package: { @@ -38,6 +42,34 @@ export function getApmPackagePolicyDefinition( }; } +function preprocessLegacyFields({ + apmServerSchema, +}: { + apmServerSchema: Record; +}) { + const copyOfApmServerSchema = { ...apmServerSchema }; + [ + { + key: 'apm-server.auth.anonymous.rate_limit.event_limit', + legacyKey: 'apm-server.rum.event_rate.limit', + }, + { + key: 'apm-server.auth.anonymous.rate_limit.ip_limit', + legacyKey: 'apm-server.rum.event_rate.lru_size', + }, + { + key: 'apm-server.auth.anonymous.allow_service', + legacyKey: 'apm-server.rum.allow_service_names', + }, + ].forEach(({ key, legacyKey }) => { + if (!copyOfApmServerSchema[key]) { + copyOfApmServerSchema[key] = copyOfApmServerSchema[legacyKey]; + delete copyOfApmServerSchema[legacyKey]; + } + }); + return copyOfApmServerSchema; +} + function getApmPackageInputVars(options: GetApmPackagePolicyDefinitionOptions) { const { apmServerSchema } = options; const apmServerConfigs = Object.entries( @@ -90,6 +122,18 @@ export const apmConfigMapping: Record< name: 'rum_allow_headers', type: 'text', }, + 'apm-server.rum.event_rate.limit': { + name: 'rum_event_rate_limit', + type: 'integer', + }, + 'apm-server.rum.allow_service_names': { + name: 'rum_allow_service_names', + type: 'text', + }, + 'apm-server.rum.event_rate.lru_size': { + name: 'rum_event_rate_lru_size', + type: 'integer', + }, 'apm-server.rum.response_headers': { name: 'rum_response_headers', type: 'yaml', diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts index 644416e41b1a6..b58a11f637c21 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts @@ -7,10 +7,12 @@ /* eslint-disable no-console */ -import { omit } from 'lodash'; import chalk from 'chalk'; import { KibanaRequest } from '../../../../../../../src/core/server'; +import { RequestStatus } from '../../../../../../../src/plugins/inspector'; +import { WrappedElasticsearchClientError } from '../../../../../observability/server'; import { inspectableEsQueriesMap } from '../../../routes/register_routes'; +import { getInspectResponse } from './get_inspect_response'; function formatObj(obj: Record) { return JSON.stringify(obj, null, 2); @@ -39,20 +41,24 @@ export async function callAsyncWithDebug({ return cb(); } - const startTime = process.hrtime(); + const hrStartTime = process.hrtime(); + const startTime = Date.now(); let res: any; - let esError = null; + let esError: WrappedElasticsearchClientError | null = null; + let esRequestStatus: RequestStatus = RequestStatus.PENDING; try { res = await cb(); + esRequestStatus = RequestStatus.OK; } catch (e) { // catch error and throw after outputting debug info esError = e; + esRequestStatus = RequestStatus.ERROR; } if (debug) { const highlightColor = esError ? 'bgRed' : 'inverse'; - const diff = process.hrtime(startTime); + const diff = process.hrtime(hrStartTime); const duration = Math.round(diff[0] * 1000 + diff[1] / 1e6); // duration in ms const { title, body } = getDebugMessage(); @@ -66,14 +72,17 @@ export async function callAsyncWithDebug({ const inspectableEsQueries = inspectableEsQueriesMap.get(request); if (!isCalledWithInternalUser && inspectableEsQueries) { - inspectableEsQueries.push({ - operationName, - response: res, - duration, - requestType, - requestParams: omit(requestParams, 'headers'), - esError: esError?.response ?? esError?.message, - }); + inspectableEsQueries.push( + getInspectResponse({ + esError, + esRequestParams: requestParams, + esRequestStatus, + esResponse: res, + kibanaRequest: request, + operationName, + startTime, + }) + ); } } diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/get_inspect_response.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/get_inspect_response.ts new file mode 100644 index 0000000000000..ae91daf9d2e0d --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/get_inspect_response.ts @@ -0,0 +1,171 @@ +/* + * 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'; +import type { KibanaRequest } from '../../../../../../../src/core/server'; +import type { + RequestStatistics, + RequestStatus, +} from '../../../../../../../src/plugins/inspector'; +import { WrappedElasticsearchClientError } from '../../../../../observability/server'; +import type { InspectResponse } from '../../../../typings/common'; + +/** + * Get statistics to show on inspector tab. + * + * If you're using searchSource (which we're not), this gets populated from + * https://github.com/elastic/kibana/blob/c7d742cb8b8935f3812707a747a139806e4be203/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts + * + * We do most of the same here, but not using searchSource. + */ +function getStats({ + esRequestParams, + esResponse, + kibanaRequest, +}: { + esRequestParams: Record; + esResponse: any; + kibanaRequest: KibanaRequest; +}) { + const stats: RequestStatistics = { + kibanaApiQueryParameters: { + label: i18n.translate( + 'xpack.apm.inspector.stats.kibanaApiQueryParametersLabel', + { + defaultMessage: 'Kibana API query parameters', + } + ), + description: i18n.translate( + 'xpack.apm.inspector.stats.kibanaApiQueryParametersDescription', + { + defaultMessage: + 'The query parameters used in the Kibana API request that initiated the Elasticsearch request.', + } + ), + value: JSON.stringify(kibanaRequest.query, null, 2), + }, + kibanaApiRoute: { + label: i18n.translate('xpack.apm.inspector.stats.kibanaApiRouteLabel', { + defaultMessage: 'Kibana API route', + }), + description: i18n.translate( + 'xpack.apm.inspector.stats.kibanaApiRouteDescription', + { + defaultMessage: + 'The route of the Kibana API request that initiated the Elasticsearch request.', + } + ), + value: `${kibanaRequest.route.method.toUpperCase()} ${ + kibanaRequest.route.path + }`, + }, + indexPattern: { + label: i18n.translate('xpack.apm.inspector.stats.indexPatternLabel', { + defaultMessage: 'Index pattern', + }), + value: esRequestParams.index, + description: i18n.translate( + 'xpack.apm.inspector.stats.indexPatternDescription', + { + defaultMessage: + 'The index pattern that connected to the Elasticsearch indices.', + } + ), + }, + }; + + if (esResponse?.hits) { + stats.hits = { + label: i18n.translate('xpack.apm.inspector.stats.hitsLabel', { + defaultMessage: 'Hits', + }), + value: `${esResponse.hits.hits.length}`, + description: i18n.translate('xpack.apm.inspector.stats.hitsDescription', { + defaultMessage: 'The number of documents returned by the query.', + }), + }; + } + + if (esResponse?.took) { + stats.queryTime = { + label: i18n.translate('xpack.apm.inspector.stats.queryTimeLabel', { + defaultMessage: 'Query time', + }), + value: i18n.translate('xpack.apm.inspector.stats.queryTimeValue', { + defaultMessage: '{queryTime}ms', + values: { queryTime: esResponse.took }, + }), + description: i18n.translate( + 'xpack.apm.inspector.stats.queryTimeDescription', + { + defaultMessage: + 'The time it took to process the query. ' + + 'Does not include the time to send the request or parse it in the browser.', + } + ), + }; + } + + if (esResponse?.hits?.total !== undefined) { + const total = esResponse.hits.total as { + relation: string; + value: number; + }; + const hitsTotalValue = + total.relation === 'eq' ? `${total.value}` : `> ${total.value}`; + + stats.hitsTotal = { + label: i18n.translate('xpack.apm.inspector.stats.hitsTotalLabel', { + defaultMessage: 'Hits (total)', + }), + value: hitsTotalValue, + description: i18n.translate( + 'xpack.apm.inspector.stats.hitsTotalDescription', + { + defaultMessage: 'The number of documents that match the query.', + } + ), + }; + } + return stats; +} + +/** + * Create a formatted response to be sent in the _inspect key for use in the + * inspector. + */ +export function getInspectResponse({ + esError, + esRequestParams, + esRequestStatus, + esResponse, + kibanaRequest, + operationName, + startTime, +}: { + esError: WrappedElasticsearchClientError | null; + esRequestParams: Record; + esRequestStatus: RequestStatus; + esResponse: any; + kibanaRequest: KibanaRequest; + operationName: string; + startTime: number; +}): InspectResponse[0] { + const id = `${operationName} (${kibanaRequest.route.path})`; + + return { + id, + json: esRequestParams.body, + name: id, + response: { + json: esError ? esError.originalError : esResponse, + }, + startTime, + stats: getStats({ esRequestParams, esResponse, kibanaRequest }), + status: esRequestStatus, + }; +} diff --git a/x-pack/plugins/apm/server/lib/services/get_throughput.ts b/x-pack/plugins/apm/server/lib/services/get_throughput.ts index e866918fc29bb..76d6000a161e6 100644 --- a/x-pack/plugins/apm/server/lib/services/get_throughput.ts +++ b/x-pack/plugins/apm/server/lib/services/get_throughput.ts @@ -8,6 +8,7 @@ import { ESFilter } from '../../../../../../src/core/types/elasticsearch'; import { SERVICE_NAME, + TRANSACTION_NAME, TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { kqlQuery, rangeQuery } from '../../../../observability/server'; @@ -25,6 +26,7 @@ interface Options { serviceName: string; setup: Setup; transactionType: string; + transactionName?: string; start: number; end: number; intervalString: string; @@ -38,6 +40,7 @@ export async function getThroughput({ serviceName, setup, transactionType, + transactionName, start, end, intervalString, @@ -56,6 +59,14 @@ export async function getThroughput({ ...kqlQuery(kuery), ]; + if (transactionName) { + filter.push({ + term: { + [TRANSACTION_NAME]: transactionName, + }, + }); + } + const params = { apm: { events: [ diff --git a/x-pack/plugins/apm/server/routes/register_routes/index.ts b/x-pack/plugins/apm/server/routes/register_routes/index.ts index 16e77f59f4d02..c660489485505 100644 --- a/x-pack/plugins/apm/server/routes/register_routes/index.ts +++ b/x-pack/plugins/apm/server/routes/register_routes/index.ts @@ -19,12 +19,9 @@ import { } from '@kbn/server-route-repository'; import { mergeRt, jsonRt } from '@kbn/io-ts-utils'; import { pickKeys } from '../../../common/utils/pick_keys'; -import { - APMRouteHandlerResources, - InspectResponse, - TelemetryUsageCounter, -} from '../typings'; +import { APMRouteHandlerResources, TelemetryUsageCounter } from '../typings'; import type { ApmPluginRequestHandlerContext } from '../typings'; +import { InspectResponse } from '../../../typings/common'; const inspectRt = t.exact( t.partial({ diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 32a7dcefb5cc8..550781cc1a020 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -451,10 +451,8 @@ const serviceThroughputRoute = createApmServerRoute({ }), query: t.intersection([ t.type({ transactionType: t.string }), - environmentRt, - kueryRt, - rangeRt, - comparisonRangeRt, + t.partial({ transactionName: t.string }), + t.intersection([environmentRt, kueryRt, rangeRt, comparisonRangeRt]), ]), }), options: { tags: ['access:apm'] }, @@ -466,6 +464,7 @@ const serviceThroughputRoute = createApmServerRoute({ environment, kuery, transactionType, + transactionName, comparisonStart, comparisonEnd, } = params.query; @@ -493,6 +492,7 @@ const serviceThroughputRoute = createApmServerRoute({ serviceName, setup, transactionType, + transactionName, throughputUnit, intervalString, }; diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 76f19a6a0ca3e..6cb43fe64ba70 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -26,15 +26,6 @@ export interface ApmPluginRequestHandlerContext extends RequestHandlerContext { rac: RacApiRequestHandlerContext; } -export type InspectResponse = Array<{ - response: any; - duration: number; - requestType: string; - requestParams: Record; - esError: Error; - operationName: string; -}>; - export interface APMRouteCreateOptions { options: { tags: Array< diff --git a/x-pack/plugins/apm/tsconfig.json b/x-pack/plugins/apm/tsconfig.json index 6eaf1a3bf1833..c1030d2a4be1d 100644 --- a/x-pack/plugins/apm/tsconfig.json +++ b/x-pack/plugins/apm/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../../../src/plugins/embeddable/tsconfig.json" }, { "path": "../../../src/plugins/home/tsconfig.json" }, { "path": "../../../src/plugins/index_pattern_management/tsconfig.json" }, + { "path": "../../../src/plugins/inspector/tsconfig.json" }, { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, diff --git a/x-pack/plugins/apm/typings/common.d.ts b/x-pack/plugins/apm/typings/common.d.ts index b94eb6cd97b06..4c0b8520924bc 100644 --- a/x-pack/plugins/apm/typings/common.d.ts +++ b/x-pack/plugins/apm/typings/common.d.ts @@ -6,6 +6,7 @@ */ import type { UnwrapPromise } from '@kbn/utility-types'; +import type { Request } from '../../../../src/plugins/inspector/common'; import '../../../typings/rison_node'; import '../../infra/types/eui'; // EUIBasicTable @@ -27,3 +28,5 @@ type AllowUnknownObjectProperties = T extends object export type PromiseValueType> = UnwrapPromise; export type Maybe = T | null | undefined; + +export type InspectResponse = Request[]; diff --git a/x-pack/plugins/cases/kibana.json b/x-pack/plugins/cases/kibana.json index 3889c559238b3..ebac6295166df 100644 --- a/x-pack/plugins/cases/kibana.json +++ b/x-pack/plugins/cases/kibana.json @@ -10,7 +10,6 @@ "id":"cases", "kibanaVersion":"kibana", "optionalPlugins":[ - "ruleRegistry", "security", "spaces" ], diff --git a/x-pack/plugins/cases/server/client/alerts/get.ts b/x-pack/plugins/cases/server/client/alerts/get.ts index 391279aab5a83..2048ccae4fa60 100644 --- a/x-pack/plugins/cases/server/client/alerts/get.ts +++ b/x-pack/plugins/cases/server/client/alerts/get.ts @@ -12,11 +12,19 @@ export const get = async ( { alertsInfo }: AlertGet, clientArgs: CasesClientArgs ): Promise => { - const { alertsService, logger } = clientArgs; + const { alertsService, scopedClusterClient, logger } = clientArgs; if (alertsInfo.length === 0) { return []; } - const alerts = await alertsService.getAlerts({ alertsInfo, logger }); - return alerts ?? []; + const alerts = await alertsService.getAlerts({ alertsInfo, scopedClusterClient, logger }); + if (!alerts) { + return []; + } + + return alerts.docs.map((alert) => ({ + id: alert._id, + index: alert._index, + ...alert._source, + })); }; diff --git a/x-pack/plugins/cases/server/client/alerts/types.ts b/x-pack/plugins/cases/server/client/alerts/types.ts index 6b3a49f20d1e5..95cd9ae33bff9 100644 --- a/x-pack/plugins/cases/server/client/alerts/types.ts +++ b/x-pack/plugins/cases/server/client/alerts/types.ts @@ -7,7 +7,17 @@ import { CaseStatuses } from '../../../common/api'; import { AlertInfo } from '../../common'; -import { Alert } from '../../services/alerts/types'; + +interface Alert { + id: string; + index: string; + destination?: { + ip: string; + }; + source?: { + ip: string; + }; +} export type CasesClientGetAlertsResponse = Alert[]; diff --git a/x-pack/plugins/cases/server/client/alerts/update_status.ts b/x-pack/plugins/cases/server/client/alerts/update_status.ts index 9c8cc33264413..a0684b59241b0 100644 --- a/x-pack/plugins/cases/server/client/alerts/update_status.ts +++ b/x-pack/plugins/cases/server/client/alerts/update_status.ts @@ -16,6 +16,6 @@ export const updateStatus = async ( { alerts }: UpdateAlertsStatusArgs, clientArgs: CasesClientArgs ): Promise => { - const { alertsService, logger } = clientArgs; - await alertsService.updateAlertsStatus({ alerts, logger }); + const { alertsService, scopedClusterClient, logger } = clientArgs; + await alertsService.updateAlertsStatus({ alerts, scopedClusterClient, logger }); }; diff --git a/x-pack/plugins/cases/server/client/attachments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts index 5393a108d6af2..166ae2ae65012 100644 --- a/x-pack/plugins/cases/server/client/attachments/add.ts +++ b/x-pack/plugins/cases/server/client/attachments/add.ts @@ -40,7 +40,12 @@ import { } from '../../services/user_actions/helpers'; import { AttachmentService, CasesService, CaseUserActionService } from '../../services'; -import { createCaseError, CommentableCase, isCommentRequestTypeGenAlert } from '../../common'; +import { + createCaseError, + CommentableCase, + createAlertUpdateRequest, + isCommentRequestTypeGenAlert, +} from '../../common'; import { CasesClientArgs, CasesClientInternal } from '..'; import { decodeCommentRequest } from '../utils'; @@ -190,9 +195,22 @@ const addGeneratedAlerts = async ( user: userDetails, commentReq: query, id: savedObjectID, - casesClientInternal, }); + if ( + (newComment.attributes.type === CommentType.alert || + newComment.attributes.type === CommentType.generatedAlert) && + caseInfo.attributes.settings.syncAlerts + ) { + const alertsToUpdate = createAlertUpdateRequest({ + comment: query, + status: subCase.attributes.status, + }); + await casesClientInternal.alerts.updateStatus({ + alerts: alertsToUpdate, + }); + } + await userActionService.bulkCreate({ unsecuredSavedObjectsClient, actions: [ @@ -368,9 +386,19 @@ export const addComment = async ( user: userInfo, commentReq: query, id: savedObjectID, - casesClientInternal, }); + if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { + const alertsToUpdate = createAlertUpdateRequest({ + comment: query, + status: updatedCase.status, + }); + + await casesClientInternal.alerts.updateStatus({ + alerts: alertsToUpdate, + }); + } + await userActionService.bulkCreate({ unsecuredSavedObjectsClient, actions: [ diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index 80e69d53e9e8b..3048cf01bb3ba 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -6,7 +6,7 @@ */ import Boom from '@hapi/boom'; -import { SavedObjectsFindResponse, SavedObject, Logger } from 'kibana/server'; +import { SavedObjectsFindResponse, SavedObject } from 'kibana/server'; import { ActionConnector, @@ -22,16 +22,26 @@ import { import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { createIncident, getCommentContextFromAttributes } from './utils'; -import { - AlertInfo, - createCaseError, - flattenCaseSavedObject, - getAlertInfoFromComments, -} from '../../common'; +import { createCaseError, flattenCaseSavedObject, getAlertInfoFromComments } from '../../common'; import { CasesClient, CasesClientArgs, CasesClientInternal } from '..'; import { Operations } from '../../authorization'; import { casesConnectors } from '../../connectors'; -import { CasesClientGetAlertsResponse } from '../alerts/types'; + +/** + * Returns true if the case should be closed based on the configuration settings and whether the case + * is a collection. Collections are not closable because we aren't allowing their status to be changed. + * In the future we could allow push to close all the sub cases of a collection but that's not currently supported. + */ +function shouldCloseByPush( + configureSettings: SavedObjectsFindResponse, + caseInfo: SavedObject +): boolean { + return ( + configureSettings.total > 0 && + configureSettings.saved_objects[0].attributes.closure_type === 'close-by-pushing' && + caseInfo.attributes.type !== CaseType.collection + ); +} /** * Parameters for pushing a case to an external system @@ -96,7 +106,9 @@ export const push = async ( const alertsInfo = getAlertInfoFromComments(theCase?.comments); - const alerts = await getAlertsCatchErrors({ casesClientInternal, alertsInfo, logger }); + const alerts = await casesClientInternal.alerts.get({ + alertsInfo, + }); const getMappingsResponse = await casesClientInternal.configuration.getMappings({ connector: theCase.connector, @@ -266,38 +278,3 @@ export const push = async ( throw createCaseError({ message: `Failed to push case: ${error}`, error, logger }); } }; - -async function getAlertsCatchErrors({ - casesClientInternal, - alertsInfo, - logger, -}: { - casesClientInternal: CasesClientInternal; - alertsInfo: AlertInfo[]; - logger: Logger; -}): Promise { - try { - return await casesClientInternal.alerts.get({ - alertsInfo, - }); - } catch (error) { - logger.error(`Failed to retrieve alerts during push: ${error}`); - return []; - } -} - -/** - * Returns true if the case should be closed based on the configuration settings and whether the case - * is a collection. Collections are not closable because we aren't allowing their status to be changed. - * In the future we could allow push to close all the sub cases of a collection but that's not currently supported. - */ -function shouldCloseByPush( - configureSettings: SavedObjectsFindResponse, - caseInfo: SavedObject -): boolean { - return ( - configureSettings.total > 0 && - configureSettings.saved_objects[0].attributes.closure_type === 'close-by-pushing' && - caseInfo.attributes.type !== CaseType.collection - ); -} diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index 611c9e09fa76e..ed19444414d57 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -12,7 +12,6 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { - Logger, SavedObject, SavedObjectsClientContract, SavedObjectsFindResponse, @@ -308,14 +307,12 @@ async function updateAlerts({ caseService, unsecuredSavedObjectsClient, casesClientInternal, - logger, }: { casesWithSyncSettingChangedToOn: UpdateRequestWithOriginalCase[]; casesWithStatusChangedAndSynced: UpdateRequestWithOriginalCase[]; caseService: CasesService; unsecuredSavedObjectsClient: SavedObjectsClientContract; casesClientInternal: CasesClientInternal; - logger: Logger; }) { /** * It's possible that a case ID can appear multiple times in each array. I'm intentionally placing the status changes @@ -364,9 +361,7 @@ async function updateAlerts({ [] ); - await casesClientInternal.alerts.updateStatus({ - alerts: alertsToUpdate, - }); + await casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate }); } function partitionPatchRequest( @@ -567,6 +562,15 @@ export const update = async ( ); }); + // Update the alert's status to match any case status or sync settings changes + await updateAlerts({ + casesWithStatusChangedAndSynced, + casesWithSyncSettingChangedToOn, + caseService, + unsecuredSavedObjectsClient, + casesClientInternal, + }); + const returnUpdatedCase = myCases.saved_objects .filter((myCase) => updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) @@ -594,17 +598,6 @@ export const update = async ( }), }); - // Update the alert's status to match any case status or sync settings changes - // Attempt to do this after creating/changing the other entities just in case it fails - await updateAlerts({ - casesWithStatusChangedAndSynced, - casesWithSyncSettingChangedToOn, - caseService, - unsecuredSavedObjectsClient, - casesClientInternal, - logger, - }); - return CasesResponseRt.encode(returnUpdatedCase); } catch (error) { const idVersions = cases.cases.map((caseInfo) => ({ diff --git a/x-pack/plugins/cases/server/client/factory.ts b/x-pack/plugins/cases/server/client/factory.ts index a1a3ccdd3bc52..2fae6996f4aa2 100644 --- a/x-pack/plugins/cases/server/client/factory.ts +++ b/x-pack/plugins/cases/server/client/factory.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { KibanaRequest, SavedObjectsServiceStart, Logger } from 'kibana/server'; +import { + KibanaRequest, + SavedObjectsServiceStart, + Logger, + ElasticsearchClient, +} from 'kibana/server'; import { SecurityPluginSetup, SecurityPluginStart } from '../../../security/server'; import { SAVED_OBJECT_TYPES } from '../../common'; import { Authorization } from '../authorization/authorization'; @@ -20,8 +25,8 @@ import { } from '../services'; import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; -import { RuleRegistryPluginStartContract } from '../../../rule_registry/server'; import { LensServerPluginSetup } from '../../../lens/server'; + import { AuthorizationAuditLogger } from '../authorization'; import { CasesClient, createCasesClient } from '.'; @@ -31,7 +36,6 @@ interface CasesClientFactoryArgs { getSpace: GetSpaceFn; featuresPluginStart: FeaturesPluginStart; actionsPluginStart: ActionsPluginStart; - ruleRegistryPluginStart?: RuleRegistryPluginStartContract; lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; } @@ -65,10 +69,12 @@ export class CasesClientFactory { */ public async create({ request, + scopedClusterClient, savedObjectsService, }: { request: KibanaRequest; savedObjectsService: SavedObjectsServiceStart; + scopedClusterClient: ElasticsearchClient; }): Promise { if (!this.isInitialized || !this.options) { throw new Error('CasesClientFactory must be initialized before calling create'); @@ -88,12 +94,9 @@ export class CasesClientFactory { const caseService = new CasesService(this.logger, this.options?.securityPluginStart?.authc); const userInfo = caseService.getUser({ request }); - const alertsClient = await this.options.ruleRegistryPluginStart?.getRacClientWithRequest( - request - ); - return createCasesClient({ - alertsService: new AlertService(alertsClient), + alertsService: new AlertService(), + scopedClusterClient, unsecuredSavedObjectsClient: savedObjectsService.getScopedClient(request, { includedHiddenTypes: SAVED_OBJECT_TYPES, // this tells the security plugin to not perform SO authorization and audit logging since we are handling diff --git a/x-pack/plugins/cases/server/client/sub_cases/update.ts b/x-pack/plugins/cases/server/client/sub_cases/update.ts index 56610ea6858e3..c8cb96cbb6b8c 100644 --- a/x-pack/plugins/cases/server/client/sub_cases/update.ts +++ b/x-pack/plugins/cases/server/client/sub_cases/update.ts @@ -246,9 +246,7 @@ async function updateAlerts({ [] ); - await casesClientInternal.alerts.updateStatus({ - alerts: alertsToUpdate, - }); + await casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate }); } catch (error) { throw createCaseError({ message: `Failed to update alert status while updating sub cases: ${JSON.stringify( @@ -357,6 +355,14 @@ export async function update({ ); }); + await updateAlerts({ + caseService, + unsecuredSavedObjectsClient, + casesClientInternal, + subCasesToSync: subCasesToSyncAlertsFor, + logger: clientArgs.logger, + }); + const returnUpdatedSubCases = updatedCases.saved_objects.reduce( (acc, updatedSO) => { const originalSubCase = subCasesMap.get(updatedSO.id); @@ -388,15 +394,6 @@ export async function update({ }), }); - // attempt to update the status of the alerts after creating all the user actions just in case it fails - await updateAlerts({ - caseService, - unsecuredSavedObjectsClient, - casesClientInternal, - subCasesToSync: subCasesToSyncAlertsFor, - logger: clientArgs.logger, - }); - return SubCasesResponseRt.encode(returnUpdatedSubCases); } catch (error) { const idVersions = query.subCases.map((subCase) => ({ diff --git a/x-pack/plugins/cases/server/client/types.ts b/x-pack/plugins/cases/server/client/types.ts index 3979c19949d9a..27829d2539c7d 100644 --- a/x-pack/plugins/cases/server/client/types.ts +++ b/x-pack/plugins/cases/server/client/types.ts @@ -6,7 +6,7 @@ */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { SavedObjectsClientContract, Logger } from 'kibana/server'; +import { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'kibana/server'; import { User } from '../../common'; import { Authorization } from '../authorization/authorization'; import { @@ -24,6 +24,7 @@ import { LensServerPluginSetup } from '../../../lens/server'; * Parameters for initializing a cases client */ export interface CasesClientArgs { + readonly scopedClusterClient: ElasticsearchClient; readonly caseConfigureService: CaseConfigureService; readonly caseService: CasesService; readonly connectorMappingsService: ConnectorMappingsService; diff --git a/x-pack/plugins/cases/server/common/models/commentable_case.ts b/x-pack/plugins/cases/server/common/models/commentable_case.ts index e540332b1ff84..856d6378d5900 100644 --- a/x-pack/plugins/cases/server/common/models/commentable_case.ts +++ b/x-pack/plugins/cases/server/common/models/commentable_case.ts @@ -34,16 +34,10 @@ import { CommentRequestUserType, CaseAttributes, } from '../../../common'; -import { - createAlertUpdateRequest, - flattenCommentSavedObjects, - flattenSubCaseSavedObject, - transformNewComment, -} from '..'; +import { flattenCommentSavedObjects, flattenSubCaseSavedObject, transformNewComment } from '..'; import { AttachmentService, CasesService } from '../../services'; import { createCaseError } from '../error'; import { countAlertsForID } from '../index'; -import { CasesClientInternal } from '../../client'; import { getOrUpdateLensReferences } from '../utils'; interface UpdateCommentResp { @@ -279,13 +273,11 @@ export class CommentableCase { user, commentReq, id, - casesClientInternal, }: { createdDate: string; user: User; commentReq: CommentRequest; id: string; - casesClientInternal: CasesClientInternal; }): Promise { try { if (commentReq.type === CommentType.alert) { @@ -302,10 +294,6 @@ export class CommentableCase { throw Boom.badRequest('The owner field of the comment must match the case'); } - // Let's try to sync the alert's status before creating the attachment, that way if the alert doesn't exist - // we'll throw an error early before creating the attachment - await this.syncAlertStatus(commentReq, casesClientInternal); - let references = this.buildRefsToCase(); if (commentReq.type === CommentType.user && commentReq?.comment) { @@ -343,26 +331,6 @@ export class CommentableCase { } } - private async syncAlertStatus( - commentRequest: CommentRequest, - casesClientInternal: CasesClientInternal - ) { - if ( - (commentRequest.type === CommentType.alert || - commentRequest.type === CommentType.generatedAlert) && - this.settings.syncAlerts - ) { - const alertsToUpdate = createAlertUpdateRequest({ - comment: commentRequest, - status: this.status, - }); - - await casesClientInternal.alerts.updateStatus({ - alerts: alertsToUpdate, - }); - } - } - private formatCollectionForEncoding(totalComment: number) { return { id: this.collection.id, diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts index 7a1efe8b366d0..fa103d4c1142d 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts @@ -24,7 +24,7 @@ describe('ITSM formatter', () => { } as CaseResponse; it('it formats correctly without alerts', async () => { - const res = format(theCase, []); + const res = await format(theCase, []); expect(res).toEqual({ dest_ip: null, source_ip: null, @@ -38,7 +38,7 @@ describe('ITSM formatter', () => { it('it formats correctly when fields do not exist ', async () => { const invalidFields = { connector: { fields: null } } as CaseResponse; - const res = format(invalidFields, []); + const res = await format(invalidFields, []); expect(res).toEqual({ dest_ip: null, source_ip: null, @@ -55,31 +55,25 @@ describe('ITSM formatter', () => { { id: 'alert-1', index: 'index-1', - source: { - destination: { ip: '192.168.1.1' }, - source: { ip: '192.168.1.2' }, - file: { - hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, - }, - url: { full: 'https://attack.com' }, + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.2' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, }, + url: { full: 'https://attack.com' }, }, { id: 'alert-2', index: 'index-2', - source: { - source: { - ip: '192.168.1.3', - }, - destination: { ip: '192.168.1.4' }, - file: { - hash: { sha256: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752' }, - }, - url: { full: 'https://attack.com/api' }, + destination: { ip: '192.168.1.4' }, + source: { ip: '192.168.1.3' }, + file: { + hash: { sha256: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752' }, }, + url: { full: 'https://attack.com/api' }, }, ]; - const res = format(theCase, alerts); + const res = await format(theCase, alerts); expect(res).toEqual({ dest_ip: '192.168.1.1,192.168.1.4', source_ip: '192.168.1.2,192.168.1.3', @@ -92,109 +86,30 @@ describe('ITSM formatter', () => { }); }); - it('it ignores alerts with an error', async () => { - const alerts = [ - { - id: 'alert-1', - index: 'index-1', - error: new Error('an error'), - source: { - destination: { ip: '192.168.1.1' }, - source: { ip: '192.168.1.2' }, - file: { - hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, - }, - url: { full: 'https://attack.com' }, - }, - }, - { - id: 'alert-2', - index: 'index-2', - source: { - source: { - ip: '192.168.1.3', - }, - destination: { ip: '192.168.1.4' }, - file: { - hash: { sha256: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752' }, - }, - url: { full: 'https://attack.com/api' }, - }, - }, - ]; - const res = format(theCase, alerts); - expect(res).toEqual({ - dest_ip: '192.168.1.4', - source_ip: '192.168.1.3', - category: 'Denial of Service', - subcategory: 'Inbound DDos', - malware_hash: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', - malware_url: 'https://attack.com/api', - priority: '2 - High', - }); - }); - - it('it ignores alerts without a source field', async () => { - const alerts = [ - { - id: 'alert-1', - index: 'index-1', - }, - { - id: 'alert-2', - index: 'index-2', - source: { - source: { - ip: '192.168.1.3', - }, - destination: { ip: '192.168.1.4' }, - file: { - hash: { sha256: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752' }, - }, - url: { full: 'https://attack.com/api' }, - }, - }, - ]; - const res = format(theCase, alerts); - expect(res).toEqual({ - dest_ip: '192.168.1.4', - source_ip: '192.168.1.3', - category: 'Denial of Service', - subcategory: 'Inbound DDos', - malware_hash: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', - malware_url: 'https://attack.com/api', - priority: '2 - High', - }); - }); - it('it handles duplicates correctly', async () => { const alerts = [ { id: 'alert-1', index: 'index-1', - source: { - destination: { ip: '192.168.1.1' }, - source: { ip: '192.168.1.2' }, - file: { - hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, - }, - url: { full: 'https://attack.com' }, + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.2' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, }, + url: { full: 'https://attack.com' }, }, { id: 'alert-2', index: 'index-2', - source: { - destination: { ip: '192.168.1.1' }, - source: { ip: '192.168.1.3' }, - file: { - hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, - }, - url: { full: 'https://attack.com/api' }, + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.3' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, }, + url: { full: 'https://attack.com/api' }, }, ]; - const res = format(theCase, alerts); + const res = await format(theCase, alerts); expect(res).toEqual({ dest_ip: '192.168.1.1', source_ip: '192.168.1.2,192.168.1.3', @@ -211,26 +126,22 @@ describe('ITSM formatter', () => { { id: 'alert-1', index: 'index-1', - source: { - destination: { ip: '192.168.1.1' }, - source: { ip: '192.168.1.2' }, - file: { - hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, - }, - url: { full: 'https://attack.com' }, + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.2' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, }, + url: { full: 'https://attack.com' }, }, { id: 'alert-2', index: 'index-2', - source: { - destination: { ip: '192.168.1.1' }, - source: { ip: '192.168.1.3' }, - file: { - hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, - }, - url: { full: 'https://attack.com/api' }, + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.3' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, }, + url: { full: 'https://attack.com/api' }, }, ]; @@ -239,7 +150,7 @@ describe('ITSM formatter', () => { connector: { fields: { ...theCase.connector.fields, destIp: false, malwareHash: false } }, } as CaseResponse; - const res = format(newCase, alerts); + const res = await format(newCase, alerts); expect(res).toEqual({ dest_ip: null, source_ip: '192.168.1.2,192.168.1.3', diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts index 88b8f79d3ba5b..b48a1b7f734c8 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts @@ -44,25 +44,23 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { ); if (fieldsToAdd.length > 0) { - sirFields = alerts - .filter((alert) => !alert.error && alert.source != null) - .reduce>((acc, alert) => { - fieldsToAdd.forEach((alertField) => { - const field = get(alertFieldMapping[alertField].alertPath, alert.source); - if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) { - manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field); - acc = { - ...acc, - [alertFieldMapping[alertField].sirFieldKey]: `${ - acc[alertFieldMapping[alertField].sirFieldKey] != null - ? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}` - : field - }`, - }; - } - }); - return acc; - }, sirFields); + sirFields = alerts.reduce>((acc, alert) => { + fieldsToAdd.forEach((alertField) => { + const field = get(alertFieldMapping[alertField].alertPath, alert); + if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) { + manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field); + acc = { + ...acc, + [alertFieldMapping[alertField].sirFieldKey]: `${ + acc[alertFieldMapping[alertField].sirFieldKey] != null + ? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}` + : field + }`, + }; + } + }); + return acc; + }, sirFields); } return { diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 49220fc716034..bb1be163585a8 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -32,7 +32,6 @@ import type { CasesRequestHandlerContext } from './types'; import { CasesClientFactory } from './client/factory'; import { SpacesPluginStart } from '../../spaces/server'; import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; -import { RuleRegistryPluginStartContract } from '../../rule_registry/server'; import { LensServerPluginSetup } from '../../lens/server'; function createConfig(context: PluginInitializerContext) { @@ -50,7 +49,6 @@ export interface PluginsStart { features: FeaturesPluginStart; spaces?: SpacesPluginStart; actions: ActionsPluginStart; - ruleRegistry?: RuleRegistryPluginStartContract; } /** @@ -139,13 +137,15 @@ export class CasePlugin { }, featuresPluginStart: plugins.features, actionsPluginStart: plugins.actions, - ruleRegistryPluginStart: plugins.ruleRegistry, lensEmbeddableFactory: this.lensEmbeddableFactory!, }); + const client = core.elasticsearch.client; + const getCasesClientWithRequest = async (request: KibanaRequest): Promise => { return this.clientFactory.create({ request, + scopedClusterClient: client.asScoped(request).asCurrentUser, savedObjectsService: core.savedObjects, }); }; @@ -171,6 +171,7 @@ export class CasePlugin { return this.clientFactory.create({ request, + scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsService: savedObjects, }); }, diff --git a/x-pack/plugins/cases/server/services/alerts/index.test.ts b/x-pack/plugins/cases/server/services/alerts/index.test.ts index 0e1ad03a32af2..d7dd44b33628b 100644 --- a/x-pack/plugins/cases/server/services/alerts/index.test.ts +++ b/x-pack/plugins/cases/server/services/alerts/index.test.ts @@ -7,73 +7,280 @@ import { CaseStatuses } from '../../../common'; import { AlertService, AlertServiceContract } from '.'; -import { loggingSystemMock } from 'src/core/server/mocks'; -import { ruleRegistryMocks } from '../../../../rule_registry/server/mocks'; -import { AlertsClient } from '../../../../rule_registry/server'; -import { PublicMethodsOf } from '@kbn/utility-types'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { ALERT_WORKFLOW_STATUS } from '../../../../rule_registry/common/technical_rule_data_field_names'; describe('updateAlertsStatus', () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); const logger = loggingSystemMock.create().get('case'); - let alertsClient: jest.Mocked>; - let alertService: AlertServiceContract; - - beforeEach(async () => { - alertsClient = ruleRegistryMocks.createAlertsClientMock.create(); - alertService = new AlertService(alertsClient); - jest.restoreAllMocks(); - }); describe('happy path', () => { - const args = { - alerts: [{ id: 'alert-id-1', index: '.siem-signals', status: CaseStatuses.closed }], - logger, - }; + let alertService: AlertServiceContract; + + beforeEach(async () => { + alertService = new AlertService(); + jest.resetAllMocks(); + }); it('updates the status of the alert correctly', async () => { + const args = { + alerts: [{ id: 'alert-id-1', index: '.siem-signals', status: CaseStatuses.closed }], + scopedClusterClient: esClient, + logger, + }; + await alertService.updateAlertsStatus(args); - expect(alertsClient.update).toHaveBeenCalledWith({ - id: 'alert-id-1', + expect(esClient.updateByQuery).toHaveBeenCalledWith({ index: '.siem-signals', - status: CaseStatuses.closed, + conflicts: 'abort', + body: { + script: { + source: `if (ctx._source['${ALERT_WORKFLOW_STATUS}'] != null) { + ctx._source['${ALERT_WORKFLOW_STATUS}'] = 'closed' + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = 'closed' + }`, + lang: 'painless', + }, + query: { + ids: { + values: ['alert-id-1'], + }, + }, + }, + ignore_unavailable: true, }); }); - it('translates the in-progress status to acknowledged', async () => { - await alertService.updateAlertsStatus({ - alerts: [{ id: 'alert-id-1', index: '.siem-signals', status: CaseStatuses['in-progress'] }], + it('buckets the alerts by index', async () => { + const args = { + alerts: [ + { id: 'id1', index: '1', status: CaseStatuses.closed }, + { id: 'id2', index: '1', status: CaseStatuses.closed }, + ], + scopedClusterClient: esClient, logger, - }); + }; - expect(alertsClient.update).toHaveBeenCalledWith({ - id: 'alert-id-1', - index: '.siem-signals', - status: 'acknowledged', + await alertService.updateAlertsStatus(args); + + expect(esClient.updateByQuery).toBeCalledTimes(1); + expect(esClient.updateByQuery).toHaveBeenCalledWith({ + index: '1', + conflicts: 'abort', + body: { + script: { + source: `if (ctx._source['${ALERT_WORKFLOW_STATUS}'] != null) { + ctx._source['${ALERT_WORKFLOW_STATUS}'] = 'closed' + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = 'closed' + }`, + lang: 'painless', + }, + query: { + ids: { + values: ['id1', 'id2'], + }, + }, + }, + ignore_unavailable: true, }); }); - it('defaults an unknown status to open', async () => { - await alertService.updateAlertsStatus({ - alerts: [{ id: 'alert-id-1', index: '.siem-signals', status: 'bananas' as CaseStatuses }], + it('translates in-progress to acknowledged', async () => { + const args = { + alerts: [{ id: 'id1', index: '1', status: CaseStatuses['in-progress'] }], + scopedClusterClient: esClient, logger, - }); + }; - expect(alertsClient.update).toHaveBeenCalledWith({ - id: 'alert-id-1', - index: '.siem-signals', - status: 'open', - }); + await alertService.updateAlertsStatus(args); + + expect(esClient.updateByQuery).toBeCalledTimes(1); + expect(esClient.updateByQuery.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "body": Object { + "query": Object { + "ids": Object { + "values": Array [ + "id1", + ], + }, + }, + "script": Object { + "lang": "painless", + "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { + ctx._source['kibana.alert.workflow_status'] = 'acknowledged' + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = 'acknowledged' + }", + }, + }, + "conflicts": "abort", + "ignore_unavailable": true, + "index": "1", + }, + ] + `); + }); + + it('makes two calls when the statuses are different', async () => { + const args = { + alerts: [ + { id: 'id1', index: '1', status: CaseStatuses.closed }, + { id: 'id2', index: '1', status: CaseStatuses.open }, + ], + scopedClusterClient: esClient, + logger, + }; + + await alertService.updateAlertsStatus(args); + + expect(esClient.updateByQuery).toBeCalledTimes(2); + // id1 should be closed + expect(esClient.updateByQuery.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "body": Object { + "query": Object { + "ids": Object { + "values": Array [ + "id1", + ], + }, + }, + "script": Object { + "lang": "painless", + "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { + ctx._source['kibana.alert.workflow_status'] = 'closed' + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = 'closed' + }", + }, + }, + "conflicts": "abort", + "ignore_unavailable": true, + "index": "1", + }, + ] + `); + + // id2 should be open + expect(esClient.updateByQuery.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + Object { + "body": Object { + "query": Object { + "ids": Object { + "values": Array [ + "id2", + ], + }, + }, + "script": Object { + "lang": "painless", + "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { + ctx._source['kibana.alert.workflow_status'] = 'open' + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = 'open' + }", + }, + }, + "conflicts": "abort", + "ignore_unavailable": true, + "index": "1", + }, + ] + `); + }); + + it('makes two calls when the indices are different', async () => { + const args = { + alerts: [ + { id: 'id1', index: '1', status: CaseStatuses.closed }, + { id: 'id2', index: '2', status: CaseStatuses.open }, + ], + scopedClusterClient: esClient, + logger, + }; + + await alertService.updateAlertsStatus(args); + + expect(esClient.updateByQuery).toBeCalledTimes(2); + // id1 should be closed in index 1 + expect(esClient.updateByQuery.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "body": Object { + "query": Object { + "ids": Object { + "values": Array [ + "id1", + ], + }, + }, + "script": Object { + "lang": "painless", + "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { + ctx._source['kibana.alert.workflow_status'] = 'closed' + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = 'closed' + }", + }, + }, + "conflicts": "abort", + "ignore_unavailable": true, + "index": "1", + }, + ] + `); + + // id2 should be open in index 2 + expect(esClient.updateByQuery.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + Object { + "body": Object { + "query": Object { + "ids": Object { + "values": Array [ + "id2", + ], + }, + }, + "script": Object { + "lang": "painless", + "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { + ctx._source['kibana.alert.workflow_status'] = 'open' + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = 'open' + }", + }, + }, + "conflicts": "abort", + "ignore_unavailable": true, + "index": "2", + }, + ] + `); }); - }); - describe('unhappy path', () => { it('ignores empty indices', async () => { - expect( - await alertService.updateAlertsStatus({ - alerts: [{ id: 'alert-id-1', index: '', status: CaseStatuses.closed }], - logger, - }) - ).toBeUndefined(); + await alertService.updateAlertsStatus({ + alerts: [{ id: 'alert-id-1', index: '', status: CaseStatuses.open }], + scopedClusterClient: esClient, + logger, + }); + + expect(esClient.updateByQuery).not.toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/cases/server/services/alerts/index.ts b/x-pack/plugins/cases/server/services/alerts/index.ts index ccb0fca4f995f..6bb2fb3ee3c56 100644 --- a/x-pack/plugins/cases/server/services/alerts/index.ts +++ b/x-pack/plugins/cases/server/services/alerts/index.ts @@ -5,71 +5,62 @@ * 2.0. */ +import pMap from 'p-map'; import { isEmpty } from 'lodash'; import type { PublicMethodsOf } from '@kbn/utility-types'; -import { Logger } from 'kibana/server'; -import { CaseStatuses, MAX_ALERTS_PER_SUB_CASE } from '../../../common'; +import { ElasticsearchClient, Logger } from 'kibana/server'; +import { CaseStatuses, MAX_ALERTS_PER_SUB_CASE, MAX_CONCURRENT_SEARCHES } from '../../../common'; import { AlertInfo, createCaseError } from '../../common'; import { UpdateAlertRequest } from '../../client/alerts/types'; -import { AlertsClient } from '../../../../rule_registry/server'; -import { Alert } from './types'; -import { STATUS_VALUES } from '../../../../rule_registry/common/technical_rule_data_field_names'; +import { + ALERT_WORKFLOW_STATUS, + STATUS_VALUES, +} from '../../../../rule_registry/common/technical_rule_data_field_names'; export type AlertServiceContract = PublicMethodsOf; interface UpdateAlertsStatusArgs { alerts: UpdateAlertRequest[]; + scopedClusterClient: ElasticsearchClient; logger: Logger; } interface GetAlertsArgs { alertsInfo: AlertInfo[]; + scopedClusterClient: ElasticsearchClient; logger: Logger; } +interface Alert { + _id: string; + _index: string; + _source: Record; +} + +interface AlertsResponse { + docs: Alert[]; +} + function isEmptyAlert(alert: AlertInfo): boolean { return isEmpty(alert.id) || isEmpty(alert.index); } export class AlertService { - constructor(private readonly alertsClient?: PublicMethodsOf) {} + constructor() {} - public async updateAlertsStatus({ alerts, logger }: UpdateAlertsStatusArgs) { + public async updateAlertsStatus({ alerts, scopedClusterClient, logger }: UpdateAlertsStatusArgs) { try { - if (!this.alertsClient) { - throw new Error( - 'Alert client is undefined, the rule registry plugin must be enabled to updated the status of alerts' - ); - } - - const alertsToUpdate = alerts.filter((alert) => !isEmptyAlert(alert)); - - if (alertsToUpdate.length <= 0) { - return; - } - - const updatedAlerts = await Promise.allSettled( - alertsToUpdate.map((alert) => - this.alertsClient?.update({ - id: alert.id, - index: alert.index, - status: translateStatus({ alert, logger }), - _version: undefined, - }) - ) + const bucketedAlerts = bucketAlertsByIndexAndStatus(alerts, logger); + const indexBuckets = Array.from(bucketedAlerts.entries()); + + await pMap( + indexBuckets, + async (indexBucket: [string, Map]) => + updateByQuery(indexBucket, scopedClusterClient), + { concurrency: MAX_CONCURRENT_SEARCHES } ); - - updatedAlerts.forEach((updatedAlert, index) => { - if (updatedAlert.status === 'rejected') { - logger.error( - `Failed to update status for alert: ${JSON.stringify(alertsToUpdate[index])}: ${ - updatedAlert.reason - }` - ); - } - }); } catch (error) { throw createCaseError({ message: `Failed to update alert status ids: ${JSON.stringify(alerts)}: ${error}`, @@ -79,51 +70,25 @@ export class AlertService { } } - public async getAlerts({ alertsInfo, logger }: GetAlertsArgs): Promise { + public async getAlerts({ + scopedClusterClient, + alertsInfo, + logger, + }: GetAlertsArgs): Promise { try { - if (!this.alertsClient) { - throw new Error( - 'Alert client is undefined, the rule registry plugin must be enabled to retrieve alerts' - ); - } + const docs = alertsInfo + .filter((alert) => !isEmptyAlert(alert)) + .slice(0, MAX_ALERTS_PER_SUB_CASE) + .map((alert) => ({ _id: alert.id, _index: alert.index })); - const alertsToGet = alertsInfo - .filter((alert) => !isEmpty(alert)) - .slice(0, MAX_ALERTS_PER_SUB_CASE); - - if (alertsToGet.length <= 0) { + if (docs.length <= 0) { return; } - const retrievedAlerts = await Promise.allSettled( - alertsToGet.map(({ id, index }) => this.alertsClient?.get({ id, index })) - ); - - retrievedAlerts.forEach((alert, index) => { - if (alert.status === 'rejected') { - logger.error( - `Failed to retrieve alert: ${JSON.stringify(alertsToGet[index])}: ${alert.reason}` - ); - } - }); + const results = await scopedClusterClient.mget({ body: { docs } }); - return retrievedAlerts.map((alert, index) => { - let source: unknown | undefined; - let error: Error | undefined; - - if (alert.status === 'fulfilled') { - source = alert.value; - } else { - error = alert.reason; - } - - return { - id: alertsToGet[index].id, - index: alertsToGet[index].index, - source, - error, - }; - }); + // @ts-expect-error @elastic/elasticsearch _source is optional + return results.body; } catch (error) { throw createCaseError({ message: `Failed to retrieve alerts ids: ${JSON.stringify(alertsInfo)}: ${error}`, @@ -134,6 +99,44 @@ export class AlertService { } } +interface TranslatedUpdateAlertRequest { + id: string; + index: string; + status: STATUS_VALUES; +} + +function bucketAlertsByIndexAndStatus( + alerts: UpdateAlertRequest[], + logger: Logger +): Map> { + return alerts.reduce>>( + (acc, alert) => { + // skip any alerts that are empty + if (isEmptyAlert(alert)) { + return acc; + } + + const translatedAlert = { ...alert, status: translateStatus({ alert, logger }) }; + const statusToAlertId = acc.get(translatedAlert.index); + + // if we haven't seen the index before + if (!statusToAlertId) { + // add a new index in the parent map, with an entry for the status the alert set to pointing + // to an initial array of only the current alert + acc.set(translatedAlert.index, createStatusToAlertMap(translatedAlert)); + } else { + // We had the index in the map so check to see if we have a bucket for the + // status, if not add a new status entry with the alert, if so update the status entry + // with the alert + updateIndexEntryWithStatus(statusToAlertId, translatedAlert); + } + + return acc; + }, + new Map() + ); +} + function translateStatus({ alert, logger, @@ -157,3 +160,53 @@ function translateStatus({ } return translatedStatus ?? 'open'; } + +function createStatusToAlertMap( + alert: TranslatedUpdateAlertRequest +): Map { + return new Map([[alert.status, [alert]]]); +} + +function updateIndexEntryWithStatus( + statusToAlerts: Map, + alert: TranslatedUpdateAlertRequest +) { + const statusBucket = statusToAlerts.get(alert.status); + + if (!statusBucket) { + statusToAlerts.set(alert.status, [alert]); + } else { + statusBucket.push(alert); + } +} + +async function updateByQuery( + [index, statusToAlertMap]: [string, Map], + scopedClusterClient: ElasticsearchClient +) { + const statusBuckets = Array.from(statusToAlertMap); + return Promise.all( + // this will create three update by query calls one for each of the three statuses + statusBuckets.map(([status, translatedAlerts]) => + scopedClusterClient.updateByQuery({ + index, + conflicts: 'abort', + body: { + script: { + source: `if (ctx._source['${ALERT_WORKFLOW_STATUS}'] != null) { + ctx._source['${ALERT_WORKFLOW_STATUS}'] = '${status}' + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = '${status}' + }`, + lang: 'painless', + }, + // the query here will contain all the ids that have the same status for the same index + // being updated + query: { ids: { values: translatedAlerts.map(({ id }) => id) } }, + }, + ignore_unavailable: true, + }) + ) + ); +} diff --git a/x-pack/plugins/data_visualizer/public/lazy_load_bundle/index.ts b/x-pack/plugins/data_visualizer/public/lazy_load_bundle/index.ts index 57f0872d62589..f04c611c2fae9 100644 --- a/x-pack/plugins/data_visualizer/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/data_visualizer/public/lazy_load_bundle/index.ts @@ -22,13 +22,13 @@ export async function lazyLoadModules(): Promise { return loadModulesPromise; } - loadModulesPromise = new Promise(async (resolve) => { - const lazyImports = await import('./lazy'); - - resolve({ - ...lazyImports, - getHttp: () => getCoreStart().http, - }); + loadModulesPromise = new Promise(async (resolve, reject) => { + try { + const lazyImports = await import('./lazy'); + resolve({ ...lazyImports, getHttp: () => getCoreStart().http }); + } catch (error) { + reject(error); + } }); return loadModulesPromise; } diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts index 9c7c6ff1e5180..192a7ffb5e782 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts @@ -44,15 +44,18 @@ export async function lazyLoadModules(): Promise { return loadModulesPromise; } - loadModulesPromise = new Promise(async (resolve) => { - const { JsonUploadAndParse, importerFactory, IndexNameForm } = await import('./lazy'); - - resolve({ - JsonUploadAndParse, - importerFactory, - getHttp, - IndexNameForm, - }); + loadModulesPromise = new Promise(async (resolve, reject) => { + try { + const { JsonUploadAndParse, importerFactory, IndexNameForm } = await import('./lazy'); + resolve({ + JsonUploadAndParse, + importerFactory, + getHttp, + IndexNameForm, + }); + } catch (error) { + reject(error); + } }); return loadModulesPromise; } diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 6f74550ad45b7..8ff3c20b7aa15 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -944,7 +944,7 @@ export function overridePackageInputs( // If there's no corresponding input on the original package policy, just // take the override value from the new package as-is. This case typically // occurs when inputs or package policies are added/removed between versions. - if (!originalInput) { + if (originalInput === undefined) { inputs.push(override as NewPackagePolicyInput); continue; } @@ -958,7 +958,7 @@ export function overridePackageInputs( } if (override.vars) { - originalInput = deepMergeVars(originalInput, override); + originalInput = deepMergeVars(originalInput, override) as NewPackagePolicyInput; } if (override.streams) { @@ -967,6 +967,11 @@ export function overridePackageInputs( (s) => s.data_stream.dataset === stream.data_stream.dataset ); + if (originalStream === undefined) { + originalInput.streams.push(stream); + continue; + } + if (typeof stream.enabled !== 'undefined' && originalStream) { originalStream.enabled = stream.enabled; } @@ -1015,12 +1020,12 @@ export function overridePackageInputs( } function deepMergeVars(original: any, override: any): any { - const result = { ...original }; - - if (!result.vars || !override.vars) { - return; + if (!original.vars) { + original.vars = { ...override.vars }; } + const result = { ...original }; + const overrideVars = Array.isArray(override.vars) ? override.vars : Object.entries(override.vars!).map(([key, rest]) => ({ @@ -1030,11 +1035,6 @@ function deepMergeVars(original: any, override: any): any { for (const { name, ...overrideVal } of overrideVars) { const originalVar = original.vars[name]; - - if (!result.vars) { - result.vars = {}; - } - result.vars[name] = { ...overrideVal, ...originalVar }; } diff --git a/x-pack/plugins/graph/public/_main.scss b/x-pack/plugins/graph/public/_main.scss index 6b32de32c06d0..22a849b0b2a60 100644 --- a/x-pack/plugins/graph/public/_main.scss +++ b/x-pack/plugins/graph/public/_main.scss @@ -21,6 +21,7 @@ */ .gphNoUserSelect { + padding-right: $euiSizeXS; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; diff --git a/x-pack/plugins/graph/public/angular/templates/_index.scss b/x-pack/plugins/graph/public/angular/templates/_index.scss deleted file mode 100644 index 0e603b5c98cbe..0000000000000 --- a/x-pack/plugins/graph/public/angular/templates/_index.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import './graph'; -@import './sidebar'; -@import './inspect'; diff --git a/x-pack/plugins/graph/public/angular/templates/index.html b/x-pack/plugins/graph/public/angular/templates/index.html deleted file mode 100644 index 14c37cab9d9fd..0000000000000 --- a/x-pack/plugins/graph/public/angular/templates/index.html +++ /dev/null @@ -1,362 +0,0 @@ -
- - - - - - - - - -
- -
-
- - - - -
- - -
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/cases/server/services/alerts/types.ts b/x-pack/plugins/graph/public/components/control_panel/index.ts similarity index 72% rename from x-pack/plugins/cases/server/services/alerts/types.ts rename to x-pack/plugins/graph/public/components/control_panel/index.ts index 5ddc57fa5861c..7c3ab15baea2d 100644 --- a/x-pack/plugins/cases/server/services/alerts/types.ts +++ b/x-pack/plugins/graph/public/components/control_panel/index.ts @@ -5,9 +5,4 @@ * 2.0. */ -export interface Alert { - id: string; - index: string; - error?: Error; - source?: unknown; -} +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/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/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/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/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..042d55418c5ab 100644 --- a/x-pack/plugins/monitoring/public/application/global_state_context.tsx +++ b/x-pack/plugins/monitoring/public/application/global_state_context.tsx @@ -16,6 +16,7 @@ interface GlobalStateProviderProps { interface State { cluster_uuid?: string; + ccs?: any; } export const GlobalStateContext = createContext({} as State); 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..ce38b00a359c8 100644 --- a/x-pack/plugins/monitoring/public/application/index.tsx +++ b/x-pack/plugins/monitoring/public/application/index.tsx @@ -11,17 +11,23 @@ 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'; 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 +37,46 @@ export const renderApp = ( const MonitoringApp: React.FC<{ core: CoreStart; plugins: MonitoringStartPluginDependencies; -}> = ({ core, plugins }) => { + externalConfig: ExternalConfig; +}> = ({ core, plugins, externalConfig }) => { const history = createPreserveQueryHistory(); return ( - - - - - - - - - - - - + + + + + + + + + + + + + + ); }; @@ -75,10 +89,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..531de505bf43d 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,74 @@ * 2.0. */ +import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui'; import React from 'react'; import { useTitle } from '../hooks/use_title'; +export interface TabMenuItem { + id: string; + label: string; + description: string; + disabled: boolean; + onClick: () => void; + testSubj: string; +} interface PageTemplateProps { title: string; + pageTitle?: string; children: React.ReactNode; + tabs?: TabMenuItem[]; } -export const PageTemplate = ({ title, children }: PageTemplateProps) => { +export const PageTemplate = ({ title, pageTitle, tabs, children }: PageTemplateProps) => { useTitle('', title); - return
{children}
; + return ( +
+ + + + +
{/* HERE GOES THE SETUP BUTTON */}
+
+ + {pageTitle && ( +
+ +

{pageTitle}

+
+
+ )} +
+
+
+ + {/* HERE GOES THE TIMEPICKER */} +
+ + {tabs && ( + + {tabs.map((item, idx) => { + return ( + + {item.label} + + ); + })} + + )} +
{children}
+
+ ); }; 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/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/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/report_listing.tsx b/x-pack/plugins/reporting/public/management/report_listing.tsx index 4e183380a6b41..c3a05042681c3 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.tsx @@ -51,7 +51,7 @@ class ReportListingUi extends Component { private isInitialJobsFetch: boolean; private licenseSubscription?: Subscription; private mounted?: boolean; - private poller?: any; + private poller?: Poller; constructor(props: Props) { super(props); @@ -119,7 +119,7 @@ class ReportListingUi extends Component { public componentWillUnmount() { this.mounted = false; - this.poller.stop(); + this.poller?.stop(); if (this.licenseSubscription) { this.licenseSubscription.unsubscribe(); diff --git a/x-pack/plugins/reporting/public/notifier/job_completion_notifications.ts b/x-pack/plugins/reporting/public/notifier/job_completion_notifications.ts index e764f94105b70..c4addfa3eedef 100644 --- a/x-pack/plugins/reporting/public/notifier/job_completion_notifications.ts +++ b/x-pack/plugins/reporting/public/notifier/job_completion_notifications.ts @@ -9,11 +9,11 @@ import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY } from '../../common/constants type JobId = string; -const set = (jobs: any) => { +const set = (jobs: string[]) => { sessionStorage.setItem(JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY, JSON.stringify(jobs)); }; -const getAll = () => { +const getAll = (): string[] => { const sessionValue = sessionStorage.getItem(JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY); return sessionValue ? JSON.parse(sessionValue) : []; }; diff --git a/x-pack/plugins/reporting/server/browsers/safe_child_process.ts b/x-pack/plugins/reporting/server/browsers/safe_child_process.ts index 9265dae23b896..70e45bf10803f 100644 --- a/x-pack/plugins/reporting/server/browsers/safe_child_process.ts +++ b/x-pack/plugins/reporting/server/browsers/safe_child_process.ts @@ -10,7 +10,7 @@ import { take, share, mapTo, delay, tap } from 'rxjs/operators'; import { LevelLogger } from '../lib'; interface IChild { - kill: (signal: string) => Promise; + kill: (signal: string) => Promise; } // Our process can get sent various signals, and when these occur we wish to diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/compatibility_shim.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/compatibility_shim.ts index f806b8a7e5bca..342e1fc7d85de 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/compatibility_shim.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/compatibility_shim.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { KibanaRequest } from 'kibana/server'; +import type { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import { url as urlUtils } from '../../../../../../../src/plugins/kibana_utils/server'; import type { LevelLogger } from '../../../lib'; import type { CreateJobFn, ReportingRequestHandlerContext } from '../../../types'; @@ -20,9 +20,9 @@ function isLegacyJob( const getSavedObjectTitle = async ( objectType: string, savedObjectId: string, - savedObjectsClient: any + savedObjectsClient: SavedObjectsClientContract ) => { - const savedObject = await savedObjectsClient.get(objectType, savedObjectId); + const savedObject = await savedObjectsClient.get<{ title: string }>(objectType, savedObjectId); return savedObject.attributes.title; }; diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index 7fc638211e87b..406beb2a56b66 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -63,7 +63,7 @@ export { BaseParams, BasePayload }; export type CreateJobFn = ( jobParams: JobParamsType, context: ReportingRequestHandlerContext, - request: KibanaRequest + request: KibanaRequest ) => Promise; // default fn type for RunTaskFnFactory diff --git a/x-pack/plugins/rule_registry/server/config.ts b/x-pack/plugins/rule_registry/server/config.ts index 830762c9b3741..8f98ceb2dd8db 100644 --- a/x-pack/plugins/rule_registry/server/config.ts +++ b/x-pack/plugins/rule_registry/server/config.ts @@ -17,6 +17,9 @@ export const config = { legacyMultiTenancy: schema.object({ enabled: schema.boolean({ defaultValue: false }), }), + indexUpgrade: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), }), }), }; diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts index a4122e3a1ffc1..2329b90898ca6 100644 --- a/x-pack/plugins/rule_registry/server/plugin.ts +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -103,6 +103,7 @@ export class RuleRegistryPlugin logger, kibanaVersion, isWriteEnabled: isWriteEnabled(this.config, this.legacyConfig), + isIndexUpgradeEnabled: this.config.unsafe.indexUpgrade.enabled, getClusterClient: async () => { const deps = await startDependencies; return deps.core.elasticsearch.client.asInternalUser; diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts index 73651ec298c36..d683cc95065e3 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts @@ -29,6 +29,7 @@ interface ConstructorOptions { getClusterClient: () => Promise; logger: Logger; isWriteEnabled: boolean; + isIndexUpgradeEnabled: boolean; } export class ResourceInstaller { @@ -115,6 +116,7 @@ export class ResourceInstaller { public async installIndexLevelResources(indexInfo: IndexInfo): Promise { await this.installWithTimeout(`resources for index ${indexInfo.baseName}`, async () => { const { componentTemplates, ilmPolicy } = indexInfo.indexOptions; + const { isIndexUpgradeEnabled } = this.options; if (ilmPolicy != null) { await this.createOrUpdateLifecyclePolicy({ @@ -138,9 +140,11 @@ export class ResourceInstaller { }) ); - // TODO: Update all existing namespaced index templates matching this index' base name + if (isIndexUpgradeEnabled) { + // TODO: Update all existing namespaced index templates matching this index' base name - await this.updateIndexMappings(indexInfo); + await this.updateIndexMappings(indexInfo); + } }); } diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts index ed3d5340756e8..c69677b091c9c 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts @@ -22,6 +22,7 @@ interface ConstructorOptions { logger: Logger; kibanaVersion: string; isWriteEnabled: boolean; + isIndexUpgradeEnabled: boolean; } /** @@ -43,6 +44,7 @@ export class RuleDataPluginService { getClusterClient: options.getClusterClient, logger: options.logger, isWriteEnabled: options.isWriteEnabled, + isIndexUpgradeEnabled: options.isIndexUpgradeEnabled, }); this.installCommonResources = Promise.resolve(right('ok')); diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index 9093d5d2e0db2..f1a9296177d9c 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -317,69 +317,73 @@ export class SessionIndex { const sessionIndexTemplateName = `${this.options.kibanaIndexName}_security_session_index_template_${SESSION_INDEX_TEMPLATE_VERSION}`; return (this.indexInitialization = new Promise(async (resolve, reject) => { - // Check if required index template exists. - let indexTemplateExists = false; try { - indexTemplateExists = ( - await this.options.elasticsearchClient.indices.existsTemplate({ - name: sessionIndexTemplateName, - }) - ).body; - } catch (err) { - this.options.logger.error( - `Failed to check if session index template exists: ${err.message}` - ); - return reject(err); - } - - // Create index template if it doesn't exist. - if (indexTemplateExists) { - this.options.logger.debug('Session index template already exists.'); - } else { + // Check if required index template exists. + let indexTemplateExists = false; try { - await this.options.elasticsearchClient.indices.putTemplate({ - name: sessionIndexTemplateName, - body: getSessionIndexTemplate(this.indexName), - }); - this.options.logger.debug('Successfully created session index template.'); + indexTemplateExists = ( + await this.options.elasticsearchClient.indices.existsTemplate({ + name: sessionIndexTemplateName, + }) + ).body; } catch (err) { - this.options.logger.error(`Failed to create session index template: ${err.message}`); + this.options.logger.error( + `Failed to check if session index template exists: ${err.message}` + ); return reject(err); } - } - // Check if required index exists. We cannot be sure that automatic creation of indices is - // always enabled, so we create session index explicitly. - let indexExists = false; - try { - indexExists = ( - await this.options.elasticsearchClient.indices.exists({ index: this.indexName }) - ).body; - } catch (err) { - this.options.logger.error(`Failed to check if session index exists: ${err.message}`); - return reject(err); - } + // Create index template if it doesn't exist. + if (indexTemplateExists) { + this.options.logger.debug('Session index template already exists.'); + } else { + try { + await this.options.elasticsearchClient.indices.putTemplate({ + name: sessionIndexTemplateName, + body: getSessionIndexTemplate(this.indexName), + }); + this.options.logger.debug('Successfully created session index template.'); + } catch (err) { + this.options.logger.error(`Failed to create session index template: ${err.message}`); + return reject(err); + } + } - // Create index if it doesn't exist. - if (indexExists) { - this.options.logger.debug('Session index already exists.'); - } else { + // Check if required index exists. We cannot be sure that automatic creation of indices is + // always enabled, so we create session index explicitly. + let indexExists = false; try { - await this.options.elasticsearchClient.indices.create({ index: this.indexName }); - this.options.logger.debug('Successfully created session index.'); + indexExists = ( + await this.options.elasticsearchClient.indices.exists({ index: this.indexName }) + ).body; } catch (err) { - // There can be a race condition if index is created by another Kibana instance. - if (err?.body?.error?.type === 'resource_already_exists_exception') { - this.options.logger.debug('Session index already exists.'); - } else { - this.options.logger.error(`Failed to create session index: ${err.message}`); - return reject(err); + this.options.logger.error(`Failed to check if session index exists: ${err.message}`); + return reject(err); + } + + // Create index if it doesn't exist. + if (indexExists) { + this.options.logger.debug('Session index already exists.'); + } else { + try { + await this.options.elasticsearchClient.indices.create({ index: this.indexName }); + this.options.logger.debug('Successfully created session index.'); + } catch (err) { + // There can be a race condition if index is created by another Kibana instance. + if (err?.body?.error?.type === 'resource_already_exists_exception') { + this.options.logger.debug('Session index already exists.'); + } else { + this.options.logger.error(`Failed to create session index: ${err.message}`); + return reject(err); + } } } - } - // Notify any consumers that are awaiting on this promise and immediately reset it. - resolve(); + // Notify any consumers that are awaiting on this promise and immediately reset it. + resolve(); + } catch (error) { + reject(error); + } }).finally(() => { this.indexInitialization = undefined; })); diff --git a/x-pack/plugins/security_solution/common/ecs/event/index.ts b/x-pack/plugins/security_solution/common/ecs/event/index.ts index 14f38480f90c8..9e2ebb059b3b3 100644 --- a/x-pack/plugins/security_solution/common/ecs/event/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/event/index.ts @@ -53,7 +53,7 @@ export enum EventCode { // Memory Protection alert MEMORY_SIGNATURE = 'memory_signature', // Memory Protection alert - MALICIOUS_THREAD = 'malicious_thread', + SHELLCODE_THREAD = 'shellcode_thread', // behavior BEHAVIOR = 'behavior', } diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index afe85e1abaa53..8f985db732b61 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -678,7 +678,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { action: 'start', kind: 'alert', category: 'malware', - code: isShellcode ? 'malicious_thread' : 'memory_signature', + code: isShellcode ? 'shellcode_thread' : 'memory_signature', id: this.seededUUIDv4(), dataset: 'endpoint', module: 'endpoint', diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts index f5cbc65effd85..674114188632b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts @@ -54,7 +54,7 @@ describe('Alert details with unmapped fields', () => { it('Displays the unmapped field on the table', () => { const expectedUnmmappedField = { - row: 90, + row: 86, field: 'unmapped', text: 'This is the unmapped field', }; diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 41027258f0bf0..c3eab5cc2a936 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -164,7 +164,7 @@ const getRunsEvery = (): Interval => ({ }); const getLookBack = (): Interval => ({ - interval: '17520', + interval: '50000', timeType: 'Hours', type: 'h', }); @@ -382,5 +382,5 @@ export const getEditedRule = (): CustomRule => ({ export const expectedExportedRule = (ruleResponse: Cypress.Response): string => { const jsonrule = ruleResponse.body; - return `{"id":"${jsonrule.id}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","created_at":"${jsonrule.created_at}","created_by":"elastic","name":"${jsonrule.name}","tags":[],"interval":"100m","enabled":false,"description":"${jsonrule.description}","risk_score":${jsonrule.risk_score},"severity":"${jsonrule.severity}","output_index":".siem-signals-default","author":[],"false_positives":[],"from":"now-17520h","rule_id":"rule_testing","max_signals":100,"risk_score_mapping":[],"severity_mapping":[],"threat":[],"to":"now","references":[],"version":1,"exceptions_list":[],"immutable":false,"type":"query","language":"kuery","index":["exceptions-*"],"query":"${jsonrule.query}","throttle":"no_actions","actions":[]}\n{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n`; + return `{"id":"${jsonrule.id}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","created_at":"${jsonrule.created_at}","created_by":"elastic","name":"${jsonrule.name}","tags":[],"interval":"100m","enabled":false,"description":"${jsonrule.description}","risk_score":${jsonrule.risk_score},"severity":"${jsonrule.severity}","output_index":".siem-signals-default","author":[],"false_positives":[],"from":"now-50000h","rule_id":"rule_testing","max_signals":100,"risk_score_mapping":[],"severity_mapping":[],"threat":[],"to":"now","references":[],"version":1,"exceptions_list":[],"immutable":false,"type":"query","language":"kuery","index":["exceptions-*"],"query":"${jsonrule.query}","throttle":"no_actions","actions":[]}\n{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n`; }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index 1520a88ec31bc..871ef0ca51ce3 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -63,7 +63,12 @@ export const closeAlerts = () => { }; export const expandFirstAlert = () => { - cy.get(EXPAND_ALERT_BTN).should('exist').first().click({ force: true }); + cy.get(EXPAND_ALERT_BTN).should('exist'); + + cy.get(EXPAND_ALERT_BTN) + .first() + .pipe(($el) => $el.trigger('click')) + .should('exist'); }; export const viewThreatIntelTab = () => cy.get(THREAT_INTEL_TAB).click(); diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts index b4e4941ff7f94..33bd8a06b9985 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts @@ -19,7 +19,7 @@ export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing', inte name: rule.name, severity: rule.severity.toLocaleLowerCase(), type: 'query', - from: 'now-17520h', + from: 'now-50000h', index: ['exceptions-*'], query: rule.customQuery, language: 'kuery', @@ -59,7 +59,7 @@ export const createCustomIndicatorRule = (rule: ThreatIndicatorRule, ruleId = 'r threat_filters: [], threat_index: rule.indicatorIndexPattern, threat_indicator_path: '', - from: 'now-17520h', + from: 'now-50000h', index: rule.index, query: rule.customQuery || '*:*', language: 'kuery', @@ -86,7 +86,7 @@ export const createCustomRuleActivated = ( name: rule.name, severity: rule.severity.toLocaleLowerCase(), type: 'query', - from: 'now-17520h', + from: 'now-50000h', index: rule.index, query: rule.customQuery, language: 'kuery', diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index f34c3f598e934..e2d27a11ed717 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -532,7 +532,6 @@ export const waitForAlertsToPopulate = async (alertCountThreshold = 1) => { cy.waitUntil( () => { refreshPage(); - cy.get(LOADING_INDICATOR).should('exist'); cy.get(LOADING_INDICATOR).should('not.exist'); return cy .get(SERVER_SIDE_EVENT_COUNT) diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index 3a0eb1a5458a8..5fda8730d5e9f 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -14,7 +14,7 @@ "cypress:run": "yarn cypress:run:reporter --browser chrome --headless --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:firefox": "yarn cypress:run:reporter --browser firefox --headless --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:reporter": "yarn cypress run --config-file ./cypress/cypress.json --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json", - "cypress:run:ccs": "yarn cypress:run:reporter --browser chrome --headless --config integrationFolder=./cypress/ccs_integration", + "cypress:run:ccs": "yarn cypress:run:reporter --browser chrome --headless --config integrationFolder=./cypress/ccs_integration; status=$?; yarn junit:merge && exit $status", "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config.ts", "cypress:run-as-ci:firefox": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/config.firefox.ts", "cypress:run:upgrade": "yarn cypress:run:reporter --browser chrome --headless --config integrationFolder=./cypress/upgrade_integration", diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts index 4df49b957ad9c..59af6737e495f 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts @@ -101,68 +101,4 @@ describe('public search functions', () => { }); expect(deepLinks.some((l) => l.id === SecurityPageName.ueba)).toBeTruthy(); }); - - describe('Detections Alerts deep links', () => { - it('should return alerts link for basic license with only read_alerts capabilities', () => { - const basicLicense = 'basic'; - const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, ({ - siem: { read_alerts: true, crud_alerts: false }, - } as unknown) as Capabilities); - - const detectionsDeepLinks = - basicLinks.find((l) => l.id === SecurityPageName.detections)?.deepLinks ?? []; - - expect( - detectionsDeepLinks.length && - detectionsDeepLinks.some((l) => l.id === SecurityPageName.alerts) - ).toBeTruthy(); - }); - - it('should return alerts link with for basic license with crud_alerts capabilities', () => { - const basicLicense = 'basic'; - const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, ({ - siem: { read_alerts: true, crud_alerts: true }, - } as unknown) as Capabilities); - - const detectionsDeepLinks = - basicLinks.find((l) => l.id === SecurityPageName.detections)?.deepLinks ?? []; - - expect( - detectionsDeepLinks.length && - detectionsDeepLinks.some((l) => l.id === SecurityPageName.alerts) - ).toBeTruthy(); - }); - - it('should NOT return alerts link for basic license with NO read_alerts capabilities', () => { - const basicLicense = 'basic'; - const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, ({ - siem: { read_alerts: false, crud_alerts: false }, - } as unknown) as Capabilities); - - const detectionsDeepLinks = - basicLinks.find((l) => l.id === SecurityPageName.detections)?.deepLinks ?? []; - - expect( - detectionsDeepLinks.length && - detectionsDeepLinks.some((l) => l.id === SecurityPageName.alerts) - ).toBeFalsy(); - }); - - it('should return alerts link for basic license with undefined capabilities', () => { - const basicLicense = 'basic'; - const basicLinks = getDeepLinks( - mockGlobalState.app.enableExperimental, - basicLicense, - undefined - ); - - const detectionsDeepLinks = - basicLinks.find((l) => l.id === SecurityPageName.detections)?.deepLinks ?? []; - - expect( - detectionsDeepLinks.length && - detectionsDeepLinks.some((l) => l.id === SecurityPageName.alerts) - ).toBeTruthy(); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index bafab2dd659f4..9f13a8be0e13a 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -368,16 +368,7 @@ export function getDeepLinks( deepLinks: [], }; } - if ( - deepLinkId === SecurityPageName.detections && - capabilities != null && - capabilities.siem.read_alerts === false - ) { - return { - ...deepLink, - deepLinks: baseDeepLinks.filter(({ id }) => id !== SecurityPageName.alerts), - }; - } + if (isPremiumLicense(licenseType) && subPluginDeepLinks?.premium) { return { ...deepLink, diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 3ec616127f243..7041cc4264504 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -59,7 +59,7 @@ const TimelineDetailsPanel = () => { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index db5eb2d882c6f..2b399a0571178 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -86,8 +86,8 @@ describe('AlertSummaryView', () => { return { category: 'event', field: 'event.code', - values: ['malicious_thread'], - originalValue: ['malicious_thread'], + values: ['shellcode_thread'], + originalValue: ['shellcode_thread'], }; } return item; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx index d8c1cc7fbfa60..da6c091ab069a 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx @@ -157,7 +157,7 @@ function getEventFieldsToDisplay({ }): EventSummaryField[] { switch (eventCode) { // memory protection fields - case EventCode.MALICIOUS_THREAD: + case EventCode.SHELLCODE_THREAD: return memoryShellCodeAlertFields; case EventCode.MEMORY_SIGNATURE: return memorySignatureAlertFields; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 29ba8fc0bd541..7b7a1ead5d702 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -33,14 +33,6 @@ import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_fe import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions'; import { mockTimelines } from '../../mock/mock_timelines_plugin'; -jest.mock('@kbn/alerts', () => ({ - useGetUserAlertsPermissions: () => ({ - loading: false, - crud: true, - read: true, - }), -})); - jest.mock('../../lib/kibana', () => ({ useKibana: () => ({ services: { diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 70fd80a13555b..e423776251bfc 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { useMemo, useEffect } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import React, { useCallback, useMemo, useEffect } from 'react'; +import { connect, ConnectedProps, useDispatch } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; @@ -108,6 +108,7 @@ const StatefulEventsViewerComponent: React.FC = ({ hasAlertsCrud = false, unit, }) => { + const dispatch = useDispatch(); const { timelines: timelinesUi } = useKibana().services; const { browserFields, @@ -149,6 +150,13 @@ const StatefulEventsViewerComponent: React.FC = ({ ) : null, [graphEventId, id] ); + const setQuery = useCallback( + (inspect, loading, refetch) => { + dispatch(inputsActions.setQuery({ id, inputId: 'global', inspect, loading, refetch })); + }, + [dispatch, id] + ); + return ( <> @@ -180,6 +188,7 @@ const StatefulEventsViewerComponent: React.FC = ({ onRuleChange, renderCellValue, rowRenderers, + setQuery, start, sort, additionalFilters, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json index d46b39b90fe5a..043ea11a51fd1 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json @@ -19,13 +19,13 @@ "Target.process.pe.original_file_name", "Target.process.pe.product", "Target.process.pgid", - "Target.process.thread.Ext.start_address_details.allocation_type", + "Target.process.Ext.memory_region.allocation_type", "Target.process.thread.Ext.start_address_bytes_disasm_hash", "Target.process.thread.Ext.start_address_allocation_offset", - "Target.process.thread.Ext.start_address_details.allocation_size", - "Target.process.thread.Ext.start_address_details.region_size", - "Target.process.thread.Ext.start_address_details.region_protection", - "Target.process.thread.Ext.start_address_details.memory_pe.imphash", + "Target.process.Ext.memory_region.allocation_size", + "Target.process.Ext.memory_region.region_size", + "Target.process.Ext.memory_region.region_protection", + "Target.process.Ext.memory_region.memory_pe.imphash", "Target.process.thread.Ext.start_address_bytes", "agent.id", "agent.type", @@ -82,6 +82,8 @@ "process.Ext.services", "process.Ext.user", "process.Ext.code_signature", + "process.Ext.token.integrity_level_name", + "process.Ext.memory_region.malware_signature.all_names", "process.executable", "process.hash.md5", "process.hash.sha1", diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 9696604ddf222..209d7d8fa273b 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -1031,7 +1031,7 @@ describe('Exception helpers', () => { ]); }); - test('it should return pre-populated memory shellcode items for event code `malicious_thread`', () => { + test('it should return pre-populated memory shellcode items for event code `shellcode_thread`', () => { const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { _id: '123', process: { @@ -1049,7 +1049,7 @@ describe('Exception helpers', () => { self_injection: true, }, event: { - code: 'malicious_thread', + code: 'shellcode_thread', }, Target: { process: { @@ -1108,52 +1108,10 @@ describe('Exception helpers', () => { value: 'high', id: '123', }, - { - field: 'Target.process.thread.Ext.start_address_details', - type: 'nested', - entries: [ - { - field: 'allocation_type', - operator: 'included', - type: 'match', - value: 'PRIVATE', - id: '123', - }, - { - field: 'allocation_size', - operator: 'included', - type: 'match', - value: '4000', - id: '123', - }, - { - field: 'region_size', - operator: 'included', - type: 'match', - value: '4000', - id: '123', - }, - { - field: 'region_protection', - operator: 'included', - type: 'match', - value: 'RWX', - id: '123', - }, - { - field: 'memory_pe.imphash', - operator: 'included', - type: 'match', - value: 'a hash', - id: '123', - }, - ], - id: '123', - }, ]); }); - test('it should return pre-populated memory shellcode items for event code `malicious_thread` and skip empty', () => { + test('it should return pre-populated memory shellcode items for event code `shellcode_thread` and skip empty', () => { const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { _id: '123', process: { @@ -1171,7 +1129,7 @@ describe('Exception helpers', () => { self_injection: true, }, event: { - code: 'malicious_thread', + code: 'shellcode_thread', }, Target: { process: { @@ -1217,41 +1175,6 @@ describe('Exception helpers', () => { value: 'high', id: '123', }, - { - field: 'Target.process.thread.Ext.start_address_details', - type: 'nested', - entries: [ - { - field: 'allocation_size', - operator: 'included', - type: 'match', - value: '4000', - id: '123', - }, - { - field: 'region_size', - operator: 'included', - type: 'match', - value: '4000', - id: '123', - }, - { - field: 'region_protection', - operator: 'included', - type: 'match', - value: 'RWX', - id: '123', - }, - { - field: 'memory_pe.imphash', - operator: 'included', - type: 'match', - value: 'a hash', - id: '123', - }, - ], - id: '123', - }, ]); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 3d219b90a2fc8..58da977fcb8f0 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -577,7 +577,7 @@ export const getPrepopulatedMemoryShellcodeException = ({ eventCode: string; alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { - const { process, Target } = alertEcsData; + const { process } = alertEcsData; const entries = filterEmptyExceptionEntries([ { field: 'Memory_protection.feature', @@ -609,44 +609,6 @@ export const getPrepopulatedMemoryShellcodeException = ({ type: 'match' as const, value: process?.Ext?.token?.integrity_level_name ?? '', }, - { - field: 'Target.process.thread.Ext.start_address_details', - type: 'nested' as const, - entries: [ - { - field: 'allocation_type', - operator: 'included' as const, - type: 'match' as const, - value: Target?.process?.thread?.Ext?.start_address_details?.allocation_type ?? '', - }, - { - field: 'allocation_size', - operator: 'included' as const, - type: 'match' as const, - value: String(Target?.process?.thread?.Ext?.start_address_details?.allocation_size) ?? '', - }, - { - field: 'region_size', - operator: 'included' as const, - type: 'match' as const, - value: String(Target?.process?.thread?.Ext?.start_address_details?.region_size) ?? '', - }, - { - field: 'region_protection', - operator: 'included' as const, - type: 'match' as const, - value: - String(Target?.process?.thread?.Ext?.start_address_details?.region_protection) ?? '', - }, - { - field: 'memory_pe.imphash', - operator: 'included' as const, - type: 'match' as const, - value: - String(Target?.process?.thread?.Ext?.start_address_details?.memory_pe?.imphash) ?? '', - }, - ], - }, ]); return { @@ -845,7 +807,7 @@ export const defaultEndpointExceptionItems = ( alertEcsData, }), ]; - case 'malicious_thread': + case 'shellcode_thread': return [ getPrepopulatedMemoryShellcodeException({ listId, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index 1f98d3b826129..b488000ac8736 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { useGetUserAlertsPermissions } from '@kbn/alerts'; import { renderHook } from '@testing-library/react-hooks'; import { KibanaPageTemplateProps } from '../../../../../../../../src/plugins/kibana_react/public'; @@ -24,7 +23,6 @@ jest.mock('../../../lib/kibana'); jest.mock('../../../hooks/use_selector'); jest.mock('../../../hooks/use_experimental_features'); jest.mock('../../../utils/route/use_route_spy'); -jest.mock('@kbn/alerts'); describe('useSecuritySolutionNavigation', () => { const mockUrlState = { [CONSTANTS.appQuery]: { query: 'host.name:"security-solution-es"', language: 'kuery' }, @@ -76,11 +74,6 @@ describe('useSecuritySolutionNavigation', () => { (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); (useDeepEqualSelector as jest.Mock).mockReturnValue({ urlState: mockUrlState }); (useRouteSpy as jest.Mock).mockReturnValue(mockRouteSpy); - (useGetUserAlertsPermissions as jest.Mock).mockReturnValue({ - loading: false, - crud: true, - read: true, - }); (useKibana as jest.Mock).mockReturnValue({ services: { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index ca574a5872761..1630bc47fd0c3 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -7,15 +7,13 @@ import React, { useCallback, useMemo } from 'react'; import { EuiSideNavItemType } from '@elastic/eui/src/components/side_nav/side_nav_types'; -import { useGetUserAlertsPermissions } from '@kbn/alerts'; import { securityNavGroup } from '../../../../app/home/home_navigations'; import { getSearch } from '../helpers'; import { PrimaryNavigationItemsProps } from './types'; -import { useGetUserCasesPermissions, useKibana } from '../../../lib/kibana'; +import { useGetUserCasesPermissions } from '../../../lib/kibana'; import { useNavigation } from '../../../lib/kibana/hooks'; import { NavTab } from '../types'; -import { SERVER_APP_ID } from '../../../../../common/constants'; export const usePrimaryNavigationItems = ({ navTabs, @@ -63,9 +61,7 @@ export const usePrimaryNavigationItems = ({ }; function usePrimaryNavigationItemsToDisplay(navTabs: Record) { - const uiCapabilities = useKibana().services.application.capabilities; const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; - const hasAlertsReadPermissions = useGetUserAlertsPermissions(uiCapabilities, SERVER_APP_ID); return useMemo( () => [ { @@ -75,9 +71,7 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { }, { ...securityNavGroup.detect, - items: hasAlertsReadPermissions.read - ? [navTabs.alerts, navTabs.rules, navTabs.exceptions] - : [navTabs.rules, navTabs.exceptions], + items: [navTabs.alerts, navTabs.rules, navTabs.exceptions], }, { ...securityNavGroup.explore, @@ -92,6 +86,6 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { items: [navTabs.endpoints, navTabs.trusted_apps, navTabs.event_filters], }, ], - [navTabs, hasCasesReadPermissions, hasAlertsReadPermissions] + [navTabs, hasCasesReadPermissions] ); } diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx b/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx index fa9de895f7d03..028473f5c2001 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx @@ -5,9 +5,8 @@ * 2.0. */ -import React, { createContext, useContext } from 'react'; +import React, { createContext, useContext, useEffect, useState } from 'react'; import { DeepReadonly } from 'utility-types'; -import { useGetUserAlertsPermissions } from '@kbn/alerts'; import { Capabilities } from '../../../../../../../src/core/public'; import { useFetchDetectionEnginePrivileges } from '../../../detections/components/user_privileges/use_fetch_detection_engine_privileges'; @@ -19,14 +18,14 @@ export interface UserPrivilegesState { listPrivileges: ReturnType; detectionEnginePrivileges: ReturnType; endpointPrivileges: EndpointPrivileges; - alertsPrivileges: ReturnType; + kibanaSecuritySolutionsPrivileges: { crud: boolean; read: boolean }; } export const initialUserPrivilegesState = (): UserPrivilegesState => ({ listPrivileges: { loading: false, error: undefined, result: undefined }, detectionEnginePrivileges: { loading: false, error: undefined, result: undefined }, endpointPrivileges: { loading: true, canAccessEndpointManagement: false, canAccessFleet: false }, - alertsPrivileges: { loading: false, read: false, crud: false }, + kibanaSecuritySolutionsPrivileges: { crud: false, read: false }, }); const UserPrivilegesContext = createContext(initialUserPrivilegesState()); @@ -43,14 +42,29 @@ export const UserPrivilegesProvider = ({ const listPrivileges = useFetchListPrivileges(); const detectionEnginePrivileges = useFetchDetectionEnginePrivileges(); const endpointPrivileges = useEndpointPrivileges(); - const alertsPrivileges = useGetUserAlertsPermissions(kibanaCapabilities, SERVER_APP_ID); + const [kibanaSecuritySolutionsPrivileges, setKibanaSecuritySolutionsPrivileges] = useState({ + crud: false, + read: false, + }); + const crud: boolean = kibanaCapabilities[SERVER_APP_ID].crud === true; + const read: boolean = kibanaCapabilities[SERVER_APP_ID].show === true; + + useEffect(() => { + setKibanaSecuritySolutionsPrivileges((currPrivileges) => { + if (currPrivileges.read !== read || currPrivileges.crud !== crud) { + return { read, crud }; + } + return currPrivileges; + }); + }, [crud, read]); + return ( {children} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx index 9cc844a80b031..6bd902658c8e4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx @@ -14,8 +14,6 @@ import { HeaderSection } from '../../../../common/components/header_section'; import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query'; import { InspectButtonContainer } from '../../../../common/components/inspect'; -import { fetchQueryRuleRegistryAlerts } from '../../../containers/detection_engine/alerts/api'; - import { getAlertsCountQuery } from './helpers'; import * as i18n from './translations'; import { AlertsCount } from './alerts_count'; @@ -49,7 +47,8 @@ export const AlertsCountPanel = memo( // ? fetchQueryRuleRegistryAlerts // : fetchQueryAlerts; - const fetchMethod = fetchQueryRuleRegistryAlerts; + // Disabling the fecth method in useQueryAlerts since it is defaulted to the old one + // const fetchMethod = fetchQueryRuleRegistryAlerts; const additionalFilters = useMemo(() => { try { @@ -73,7 +72,6 @@ export const AlertsCountPanel = memo( request, refetch, } = useQueryAlerts<{}, AlertsCountAggregation>({ - fetchMethod, query: getAlertsCountQuery(selectedStackByOption, from, to, additionalFilters), indexName: signalIndexName, }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index b296371bae58d..2182ed7da0c4f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -43,7 +43,6 @@ import type { AlertsStackByField } from '../common/types'; import { KpiPanel, StackBySelect } from '../common/components'; import { useInspectButton } from '../common/hooks'; -import { fetchQueryRuleRegistryAlerts } from '../../../containers/detection_engine/alerts/api'; const defaultTotalAlertsObj: AlertsTotal = { value: 0, @@ -117,16 +116,12 @@ export const AlertsHistogramPanel = memo( request, refetch, } = useQueryAlerts<{}, AlertsAggregation>({ - fetchMethod: fetchQueryRuleRegistryAlerts, - query: { - index: signalIndexName, - ...getAlertsHistogramQuery( - selectedStackByOption, - from, - to, - buildCombinedQueries(combinedQueries) - ), - }, + query: getAlertsHistogramQuery( + selectedStackByOption, + from, + to, + buildCombinedQueries(combinedQueries) + ), indexName: signalIndexName, }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 4b3c792319cd1..e179c02987462 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -381,7 +381,7 @@ export const AlertsTableComponent: React.FC = ({ pageFilters={defaultFiltersMemo} defaultCellActions={defaultCellActions} defaultModel={defaultTimelineModel} - entityType="alerts" + entityType="events" end={to} currentFilter={filterGroup} id={timelineId} @@ -392,7 +392,7 @@ export const AlertsTableComponent: React.FC = ({ start={from} utilityBar={utilityBarCallback} additionalFilters={additionalFiltersComponent} - hasAlertsCrud={hasIndexWrite} + hasAlertsCrud={hasIndexWrite && hasIndexMaintenance} /> ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx index eb31a59f0ca87..9568f9c894e24 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx @@ -13,14 +13,6 @@ import React from 'react'; import { Ecs } from '../../../../../common/ecs'; import { mockTimelines } from '../../../../common/mock/mock_timelines_plugin'; -jest.mock('@kbn/alerts', () => ({ - useGetUserAlertsPermissions: () => ({ - loading: false, - crud: true, - read: true, - }), -})); - const ecsRowData: Ecs = { _id: '1', agent: { type: ['blah'] } }; const props = { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx index 3568972aef2e9..8da4ce1c3ed7f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx @@ -7,15 +7,12 @@ import { useCallback } from 'react'; import { useDispatch } from 'react-redux'; -import { useGetUserAlertsPermissions } from '@kbn/alerts'; import { useStatusBulkActionItems } from '../../../../../../timelines/public'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { timelineActions } from '../../../../timelines/store/timeline'; +import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges'; import { SetEventsDeletedProps, SetEventsLoadingProps } from '../types'; - -import { useKibana } from '../../../../common/lib/kibana'; -import { SERVER_APP_ID } from '../../../../../common/constants'; interface Props { alertStatus?: Status; closePopover: () => void; @@ -34,8 +31,7 @@ export const useAlertsActions = ({ refetch, }: Props) => { const dispatch = useDispatch(); - const uiCapabilities = useKibana().services.application.capabilities; - const alertsPrivileges = useGetUserAlertsPermissions(uiCapabilities, SERVER_APP_ID); + const { hasIndexWrite, hasKibanaCRUD } = useAlertsPrivileges(); const onStatusUpdate = useCallback(() => { closePopover(); @@ -66,9 +62,10 @@ export const useAlertsActions = ({ setEventsDeleted, onUpdateSuccess: onStatusUpdate, onUpdateFailure: onStatusUpdate, + timelineId, }); return { - actionItems: alertsPrivileges.crud ? actionItems : [], + actionItems: hasIndexWrite && hasKibanaCRUD ? actionItems : [], }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/translations.tsx index 3509ad73001ec..0d628d89c0925 100644 --- a/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/translations.tsx @@ -9,10 +9,6 @@ import { EuiCode } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { - DetectionsRequirementsLink, - SecuritySolutionRequirementsLink, -} from '../../../../common/components/links_to_docs'; import { DEFAULT_ITEMS_INDEX, DEFAULT_LISTS_INDEX, @@ -21,6 +17,10 @@ import { } from '../../../../../common/constants'; import { CommaSeparatedValues } from './comma_separated_values'; import { MissingPrivileges } from './use_missing_privileges'; +import { + DetectionsRequirementsLink, + SecuritySolutionRequirementsLink, +} from '../../../../common/components/links_to_docs'; export const MISSING_PRIVILEGES_CALLOUT_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageTitle', @@ -46,17 +46,17 @@ const CANNOT_EDIT_LISTS = i18n.translate( const CANNOT_EDIT_ALERTS = i18n.translate( 'xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.cannotEditAlerts', { - defaultMessage: 'Without these privileges, you cannot open or close alerts.', + defaultMessage: 'Without these privileges, you cannot view or change status of alerts.', } ); export const missingPrivilegesCallOutBody = ({ indexPrivileges, - featurePrivileges, + featurePrivileges = [], }: MissingPrivileges) => ( @@ -77,23 +77,30 @@ export const missingPrivilegesCallOutBody = ({ {indexPrivileges.map(([index, missingPrivileges]) => (
  • {missingIndexPrivileges(index, missingPrivileges)}
  • ))} - - - ) : null, - featurePrivileges: - featurePrivileges.length > 0 ? ( - <> - -
      - {featurePrivileges.map(([feature, missingPrivileges]) => ( + { + // TODO: Uncomment once RBAC for alerts is reenabled + /* {featurePrivileges.map(([feature, missingPrivileges]) => (
    • {missingFeaturePrivileges(feature, missingPrivileges)}
    • - ))} + ))} */ + }
    ) : null, + // TODO: Uncomment once RBAC for alerts is reenabled + // featurePrivileges: + // featurePrivileges.length > 0 ? ( + // <> + // + //
      + // {featurePrivileges.map(([feature, missingPrivileges]) => ( + //
    • {missingFeaturePrivileges(feature, missingPrivileges)}
    • + // ))} + //
    + // + // ) : null, docs: (
    • @@ -152,14 +159,15 @@ const missingIndexPrivileges = (index: string, privileges: string[]) => ( /> ); -const missingFeaturePrivileges = (feature: string, privileges: string[]) => ( - , - index: {feature}, - explanation: getPrivilegesExplanation(privileges, feature), - }} - /> -); +// TODO: Uncomment once RBAC for alerts is reenabled +// const missingFeaturePrivileges = (feature: string, privileges: string[]) => ( +// , +// index: {feature}, +// explanation: getPrivilegesExplanation(privileges, feature), +// }} +// /> +// ); diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/use_missing_privileges.ts b/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/use_missing_privileges.ts index ea2b081239fda..eec9bd1f09053 100644 --- a/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/use_missing_privileges.ts +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/use_missing_privileges.ts @@ -40,14 +40,18 @@ export interface MissingPrivileges { } export const useMissingPrivileges = (): MissingPrivileges => { - const { listPrivileges } = useUserPrivileges(); + const { detectionEnginePrivileges, listPrivileges } = useUserPrivileges(); const [{ canUserCRUD }] = useUserData(); return useMemo(() => { const featurePrivileges: MissingFeaturePrivileges[] = []; const indexPrivileges: MissingIndexPrivileges[] = []; - if (canUserCRUD == null || listPrivileges.result == null) { + if ( + canUserCRUD == null || + listPrivileges.result == null || + detectionEnginePrivileges.result == null + ) { /** * Do not check privileges till we get all the data. That helps to reduce * subsequent layout shift while loading and skip unneeded re-renders. @@ -72,9 +76,16 @@ export const useMissingPrivileges = (): MissingPrivileges => { indexPrivileges.push(missingListsPrivileges); } + const missingDetectionPrivileges = getMissingIndexPrivileges( + detectionEnginePrivileges.result.index + ); + if (missingDetectionPrivileges) { + indexPrivileges.push(missingDetectionPrivileges); + } + return { featurePrivileges, indexPrivileges, }; - }, [canUserCRUD, listPrivileges]); + }, [canUserCRUD, listPrivileges, detectionEnginePrivileges]); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx index 76c0017f6fa9c..bba652bcdd030 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx @@ -24,14 +24,14 @@ jest.mock('../../../common/lib/kibana', () => ({ useKibana: jest.fn(), useGetUserCasesPermissions: jest.fn().mockReturnValue({ crud: true }), })); +jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges', () => ({ + useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }), +})); jest.mock('../../../cases/components/use_insert_timeline'); jest.mock('../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), })); -jest.mock('@kbn/alerts', () => { - return { useGetUserAlertsPermissions: jest.fn().mockReturnValue({ crud: true }) }; -}); jest.mock('../../../common/utils/endpoint_alert_check', () => { return { endpointAlertCheck: jest.fn().mockReturnValue(true) }; diff --git a/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx index 9972233dce351..67863f05c7d83 100644 --- a/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx @@ -43,6 +43,7 @@ describe('useUserInfo', () => { expect(result.all).toHaveLength(1); expect(result.current).toEqual({ canUserCRUD: null, + canUserREAD: null, hasEncryptionKey: null, hasIndexManage: null, hasIndexMaintenance: null, diff --git a/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx b/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx index da6df631d951e..9c81b51445f60 100644 --- a/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx @@ -10,11 +10,11 @@ import React, { useEffect, useReducer, Dispatch, createContext, useContext } fro import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; import { useSignalIndex } from '../../containers/detection_engine/alerts/use_signal_index'; -import { useKibana } from '../../../common/lib/kibana'; import { useCreateTransforms } from '../../../transforms/containers/use_create_transforms'; export interface State { canUserCRUD: boolean | null; + canUserREAD: boolean | null; hasIndexManage: boolean | null; hasIndexMaintenance: boolean | null; hasIndexWrite: boolean | null; @@ -30,6 +30,7 @@ export interface State { export const initialState: State = { canUserCRUD: null, + canUserREAD: null, hasIndexManage: null, hasIndexMaintenance: null, hasIndexWrite: null, @@ -77,10 +78,6 @@ export type Action = type: 'updateHasEncryptionKey'; hasEncryptionKey: boolean | null; } - | { - type: 'updateCanUserCRUD'; - canUserCRUD: boolean | null; - } | { type: 'updateSignalIndexName'; signalIndexName: string | null; @@ -88,6 +85,14 @@ export type Action = | { type: 'updateSignalIndexMappingOutdated'; signalIndexMappingOutdated: boolean | null; + } + | { + type: 'updateCanUserCRUD'; + canUserCRUD: boolean | null; + } + | { + type: 'updateCanUserREAD'; + canUserREAD: boolean | null; }; export const userInfoReducer = (state: State, action: Action): State => { @@ -146,12 +151,6 @@ export const userInfoReducer = (state: State, action: Action): State => { hasEncryptionKey: action.hasEncryptionKey, }; } - case 'updateCanUserCRUD': { - return { - ...state, - canUserCRUD: action.canUserCRUD, - }; - } case 'updateSignalIndexName': { return { ...state, @@ -164,6 +163,18 @@ export const userInfoReducer = (state: State, action: Action): State => { signalIndexMappingOutdated: action.signalIndexMappingOutdated, }; } + case 'updateCanUserCRUD': { + return { + ...state, + canUserCRUD: action.canUserCRUD, + }; + } + case 'updateCanUserREAD': { + return { + ...state, + canUserREAD: action.canUserREAD, + }; + } default: return state; } @@ -187,6 +198,7 @@ export const useUserInfo = (): State => { const [ { canUserCRUD, + canUserREAD, hasIndexManage, hasIndexMaintenance, hasIndexWrite, @@ -210,6 +222,8 @@ export const useUserInfo = (): State => { hasIndexUpdateDelete: hasApiIndexUpdateDelete, hasIndexWrite: hasApiIndexWrite, hasIndexRead: hasApiIndexRead, + hasKibanaCRUD, + hasKibanaREAD, } = useAlertsPrivileges(); const { loading: indexNameLoading, @@ -221,8 +235,17 @@ export const useUserInfo = (): State => { const { createTransforms } = useCreateTransforms(); - const uiCapabilities = useKibana().services.application.capabilities; - const capabilitiesCanUserCRUD: boolean = uiCapabilities.siem.crud === true; + useEffect(() => { + if (!loading && canUserCRUD !== hasKibanaCRUD) { + dispatch({ type: 'updateCanUserCRUD', canUserCRUD: hasKibanaCRUD }); + } + }, [dispatch, loading, canUserCRUD, hasKibanaCRUD]); + + useEffect(() => { + if (!loading && canUserREAD !== hasKibanaREAD) { + dispatch({ type: 'updateCanUserREAD', canUserREAD: hasKibanaREAD }); + } + }, [dispatch, loading, canUserREAD, hasKibanaREAD]); useEffect(() => { if (loading !== (privilegeLoading || indexNameLoading)) { @@ -293,12 +316,6 @@ export const useUserInfo = (): State => { } }, [dispatch, loading, hasEncryptionKey, isApiEncryptionKey]); - useEffect(() => { - if (!loading && canUserCRUD !== capabilitiesCanUserCRUD) { - dispatch({ type: 'updateCanUserCRUD', canUserCRUD: capabilitiesCanUserCRUD }); - } - }, [dispatch, loading, canUserCRUD, capabilitiesCanUserCRUD]); - useEffect(() => { if (!loading && signalIndexName !== apiSignalIndexName && apiSignalIndexName != null) { dispatch({ type: 'updateSignalIndexName', signalIndexName: apiSignalIndexName }); @@ -351,6 +368,7 @@ export const useUserInfo = (): State => { isAuthenticated, hasEncryptionKey, canUserCRUD, + canUserREAD, hasIndexManage, hasIndexMaintenance, hasIndexWrite, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx index 64d9db80316a9..cbab24835c1ac 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx @@ -87,10 +87,10 @@ const userPrivilegesInitial: ReturnType = { error: undefined, }, endpointPrivileges: { loading: true, canAccessEndpointManagement: false, canAccessFleet: false }, - alertsPrivileges: { loading: true, crud: false, read: false }, + kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, }; -describe('usePrivilegeUser', () => { +describe('useAlertsPrivileges', () => { let appToastsMock: jest.Mocked>; beforeEach(() => { @@ -113,13 +113,15 @@ describe('usePrivilegeUser', () => { hasIndexMaintenance: null, hasIndexWrite: null, hasIndexUpdateDelete: null, + hasKibanaCRUD: false, + hasKibanaREAD: false, isAuthenticated: null, loading: false, }); }); }); - test('if there is an error when fetching user privilege, we should get back false for every properties', async () => { + test('if there is an error when fetching user privilege, we should get back false for all index related properties', async () => { const userPrivileges = produce(userPrivilegesInitial, (draft) => { draft.detectionEnginePrivileges.error = new Error('Something went wrong'); }); @@ -137,6 +139,8 @@ describe('usePrivilegeUser', () => { hasIndexRead: false, hasIndexWrite: false, hasIndexUpdateDelete: false, + hasKibanaCRUD: true, + hasKibanaREAD: true, isAuthenticated: false, loading: false, }); @@ -162,9 +166,11 @@ describe('usePrivilegeUser', () => { hasEncryptionKey: true, hasIndexManage: false, hasIndexMaintenance: true, - hasIndexRead: false, - hasIndexWrite: false, + hasIndexRead: true, + hasIndexWrite: true, hasIndexUpdateDelete: true, + hasKibanaCRUD: true, + hasKibanaREAD: true, isAuthenticated: true, loading: false, }); @@ -187,9 +193,67 @@ describe('usePrivilegeUser', () => { hasEncryptionKey: true, hasIndexManage: true, hasIndexMaintenance: true, - hasIndexRead: false, - hasIndexWrite: false, + hasIndexRead: true, + hasIndexWrite: true, + hasIndexUpdateDelete: true, + hasKibanaCRUD: true, + hasKibanaREAD: true, + isAuthenticated: true, + loading: false, + }); + }); + }); + + test('returns "hasKibanaCRUD" as false if user does not have SIEM Kibana "all" privileges', async () => { + const userPrivileges = produce(userPrivilegesInitial, (draft) => { + draft.detectionEnginePrivileges.result = privilege; + draft.kibanaSecuritySolutionsPrivileges = { crud: false, read: true }; + }); + useUserPrivilegesMock.mockReturnValue(userPrivileges); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useAlertsPrivileges() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + hasEncryptionKey: true, + hasIndexManage: true, + hasIndexMaintenance: true, + hasIndexRead: true, + hasIndexWrite: true, + hasIndexUpdateDelete: true, + hasKibanaCRUD: false, + hasKibanaREAD: true, + isAuthenticated: true, + loading: false, + }); + }); + }); + + test('returns "hasKibanaREAD" as false if user does not have at least SIEM Kibana "read" privileges', async () => { + const userPrivileges = produce(userPrivilegesInitial, (draft) => { + draft.detectionEnginePrivileges.result = privilege; + draft.kibanaSecuritySolutionsPrivileges = { crud: false, read: false }; + }); + useUserPrivilegesMock.mockReturnValue(userPrivileges); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useAlertsPrivileges() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + hasEncryptionKey: true, + hasIndexManage: true, + hasIndexMaintenance: true, + hasIndexRead: true, + hasIndexWrite: true, hasIndexUpdateDelete: true, + hasKibanaCRUD: false, + hasKibanaREAD: false, isAuthenticated: true, loading: false, }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx index 1d9b8228b5070..b377eda49d0cd 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx @@ -20,6 +20,8 @@ export interface AlertsPrivelegesState { hasIndexUpdateDelete: boolean | null; hasIndexMaintenance: boolean | null; hasIndexRead: boolean | null; + hasKibanaCRUD: boolean; + hasKibanaREAD: boolean; } /** * Hook to get user privilege from @@ -34,8 +36,13 @@ export const useAlertsPrivileges = (): UseAlertsPrivelegesReturn => { hasIndexWrite: null, hasIndexUpdateDelete: null, hasIndexMaintenance: null, + hasKibanaCRUD: false, + hasKibanaREAD: false, }); - const { detectionEnginePrivileges, alertsPrivileges } = useUserPrivileges(); + const { + detectionEnginePrivileges, + kibanaSecuritySolutionsPrivileges: { crud: hasKibanaCRUD, read: hasKibanaREAD }, + } = useUserPrivileges(); useEffect(() => { if (detectionEnginePrivileges.error != null) { @@ -47,9 +54,11 @@ export const useAlertsPrivileges = (): UseAlertsPrivelegesReturn => { hasIndexWrite: false, hasIndexUpdateDelete: false, hasIndexMaintenance: false, + hasKibanaCRUD, + hasKibanaREAD, }); } - }, [detectionEnginePrivileges.error]); + }, [detectionEnginePrivileges.error, hasKibanaCRUD, hasKibanaREAD]); useEffect(() => { if (detectionEnginePrivileges.result != null) { @@ -62,13 +71,19 @@ export const useAlertsPrivileges = (): UseAlertsPrivelegesReturn => { hasEncryptionKey: privilege.has_encryption_key, hasIndexManage: privilege.index[indexName].manage && privilege.cluster.manage, hasIndexMaintenance: privilege.index[indexName].maintenance, - hasIndexRead: alertsPrivileges.read, - hasIndexWrite: alertsPrivileges.crud, + hasIndexRead: privilege.index[indexName].read, + hasIndexWrite: + privilege.index[indexName].create || + privilege.index[indexName].create_doc || + privilege.index[indexName].index || + privilege.index[indexName].write, hasIndexUpdateDelete: privilege.index[indexName].write, + hasKibanaCRUD, + hasKibanaREAD, }); } } - }, [detectionEnginePrivileges.result, alertsPrivileges]); + }, [detectionEnginePrivileges.result, hasKibanaCRUD, hasKibanaREAD]); return { loading: detectionEnginePrivileges.loading, ...privileges }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx index 6d68dae375866..ade83fed4fd6b 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx @@ -14,13 +14,6 @@ import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; jest.mock('./api'); jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('../../../../common/components/user_privileges/use_endpoint_privileges'); -jest.mock('@kbn/alerts', () => ({ - useGetUserAlertsPermissions: () => ({ - loading: false, - crud: true, - read: true, - }), -})); describe('useSignalIndex', () => { let appToastsMock: jest.Mocked>; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx index dbd59d2510238..18952feee528b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx @@ -7,15 +7,15 @@ import React, { useEffect } from 'react'; import { Route, Switch } from 'react-router-dom'; -import { useGetUserAlertsPermissions } from '@kbn/alerts'; -import { ALERTS_PATH, SecurityPageName, SERVER_APP_ID } from '../../../../common/constants'; +import { ALERTS_PATH, SecurityPageName } from '../../../../common/constants'; import { NotFoundPage } from '../../../app/404'; import * as i18n from './translations'; import { TrackApplicationView } from '../../../../../../../src/plugins/usage_collection/public'; import { DetectionEnginePage } from '../../pages/detection_engine/detection_engine'; import { useKibana } from '../../../common/lib/kibana'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; +import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; const AlertsRoute = () => ( @@ -25,15 +25,12 @@ const AlertsRoute = () => ( ); const AlertsContainerComponent: React.FC = () => { - const { - chrome, - application: { capabilities }, - } = useKibana().services; - const userPermissions = useGetUserAlertsPermissions(capabilities, SERVER_APP_ID); + const { chrome } = useKibana().services; + const { hasIndexRead, hasIndexWrite } = useAlertsPrivileges(); useEffect(() => { // if the user is read only then display the glasses badge in the global navigation header - if (userPermissions != null && !userPermissions.crud && userPermissions.read) { + if (!hasIndexWrite && hasIndexRead) { chrome.setBadge({ text: i18n.READ_ONLY_BADGE_TEXT, tooltip: i18n.READ_ONLY_BADGE_TOOLTIP, @@ -45,7 +42,7 @@ const AlertsContainerComponent: React.FC = () => { return () => { chrome.setBadge(); }; - }, [userPermissions, chrome]); + }, [chrome, hasIndexRead, hasIndexWrite]); return ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index a92f4d706dc7c..0d0c51bc540b4 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -80,7 +80,7 @@ jest.mock('../../../common/lib/kibana', () => { docLinks: { links: { siem: { - gettingStarted: 'link', + privileges: 'link', }, }, }, @@ -107,6 +107,7 @@ describe('DetectionEnginePageComponent', () => { (useUserData as jest.Mock).mockReturnValue([ { hasIndexRead: true, + canUserREAD: true, }, ]); (useSourcererScope as jest.Mock).mockReturnValue({ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index d6531198c1884..71542e6931489 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -5,6 +5,10 @@ * 2.0. */ +// No bueno, I know! Encountered when reverting RBAC work post initial BCs +// Don't want to include large amounts of refactor in this temporary workaround +// TODO: Refactor code - component can be broken apart +/* eslint-disable complexity */ import { EuiFlexGroup, EuiFlexItem, @@ -17,7 +21,6 @@ import { noop } from 'lodash/fp'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import { connect, ConnectedProps, useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; -import { AlertsFeatureNoPermissions } from '@kbn/alerts'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; @@ -73,6 +76,7 @@ import { AlertsTableFilterGroup, FILTER_OPEN, } from '../../components/alerts_table/alerts_filter_group'; +import { EmptyPage } from '../../../common/components/empty_page'; /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. @@ -117,7 +121,10 @@ const DetectionEnginePageComponent: React.FC = ({ isAuthenticated: isUserAuthenticated, hasEncryptionKey, signalIndexName, - hasIndexWrite, + hasIndexWrite = false, + hasIndexMaintenance = false, + canUserCRUD = false, + canUserREAD, hasIndexRead, }, ] = useUserData(); @@ -249,6 +256,18 @@ const DetectionEnginePageComponent: React.FC = ({ [containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable] ); + const emptyPageActions = useMemo( + () => ({ + feature: { + icon: 'documents', + label: i18n.GO_TO_DOCUMENTATION, + url: `${docLinks.links.siem.privileges}`, + target: '_blank', + }, + }), + [docLinks] + ); + if (isUserAuthenticated != null && !isUserAuthenticated && !loading) { return ( @@ -275,92 +294,89 @@ const DetectionEnginePageComponent: React.FC = ({ {hasEncryptionKey != null && !hasEncryptionKey && } - {indicesExist ? ( + {indicesExist && (hasIndexRead === false || canUserREAD === false) ? ( + + ) : indicesExist && hasIndexRead && canUserREAD ? ( - {hasIndexRead ? ( - <> - - - - - - - - {i18n.BUTTON_MANAGE_RULES} - - - - - - - - - {timelinesUi.getLastUpdated({ - updatedAt: updatedAt || 0, - showUpdating: loading, - })} - - - - - - - + + + + + + + + {i18n.BUTTON_MANAGE_RULES} + + + + + + + + + {timelinesUi.getLastUpdated({ + updatedAt: updatedAt || 0, + showUpdating: loading, + })} + + + + + + + - - - - + + + + - - + + - - - - ) : ( - - )} + ) : ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx index c1d674ce456ff..0c67a19e59e32 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx @@ -45,13 +45,6 @@ jest.mock('../../../../../common/containers/use_global_time', () => ({ setQuery: jest.fn(), }), })); -jest.mock('@kbn/alerts', () => ({ - useGetUserAlertsPermissions: () => ({ - loading: false, - crud: true, - read: true, - }), -})); jest.mock('react-router-dom', () => { const originalModule = jest.requireActual('react-router-dom'); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts index 96e423aff1658..fedf119025304 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts @@ -143,3 +143,18 @@ export const ML_RULES_UNAVAILABLE = (totalRules: number) => defaultMessage: '{totalRules} {totalRules, plural, =1 {rule requires} other {rules require}} Machine Learning to enable.', }); + +export const FEATURE_NO_PERMISSIONS_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.noPermissionsTitle', + { + defaultMessage: 'Privileges required', + } +); + +export const ALERTS_FEATURE_NO_PERMISSIONS_MSG = i18n.translate( + 'xpack.securitySolution.detectionEngine.noPermissionsMessage', + { + defaultMessage: + 'To view alerts, you must update privileges. For more information, contact your Kibana administrator.', + } +); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 8deb1b93f9728..ffafe211960d5 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -34,6 +34,7 @@ import { useDeepEqualSelector } from '../../common/hooks/use_selector'; import { ThreatIntelLinkPanel } from '../components/overview_cti_links'; import { useIsThreatIntelModuleEnabled } from '../containers/overview_cti_links/use_is_threat_intel_module_enabled'; import { useUserPrivileges } from '../../common/components/user_privileges'; +import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges'; const SidebarFlexItem = styled(EuiFlexItem)` margin-right: 24px; @@ -72,8 +73,8 @@ const OverviewComponent = () => { }, [addMessage]); const { endpointPrivileges: { canAccessFleet }, - alertsPrivileges, } = useUserPrivileges(); + const { hasIndexRead, hasKibanaREAD } = useAlertsPrivileges(); const isThreatIntelModuleEnabled = useIsThreatIntelModuleEnabled(); return ( <> @@ -98,7 +99,7 @@ const OverviewComponent = () => { - {alertsPrivileges?.read && ( + {hasIndexRead && hasKibanaREAD && ( <> diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 4a951dfff45d7..93fa70ddd9bfb 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -33,7 +33,6 @@ import { import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { initTelemetry } from './common/lib/telemetry'; import { KibanaServices } from './common/lib/kibana/services'; -import { BASE_RAC_ALERTS_API_PATH } from '../../rule_registry/common/constants'; import { APP_ID, @@ -42,7 +41,7 @@ import { APP_PATH, DEFAULT_INDEX_KEY, APP_ICON_SOLUTION, - SERVER_APP_ID, + DETECTION_ENGINE_INDEX_URL, } from '../common/constants'; import { getDeepLinks, updateGlobalNavigation } from './app/deep_links'; @@ -354,14 +353,17 @@ export class Plugin implements IPlugin ({ - useGetUserAlertsPermissions: () => ({ - loading: false, - crud: true, - read: true, - }), -})); - describe('Details Panel Component', () => { const state: State = { ...mockGlobalState }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx index cad6648cd1f38..5ed9398a621e8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx @@ -29,14 +29,6 @@ jest.mock( }) ); -jest.mock('@kbn/alerts', () => ({ - useGetUserAlertsPermissions: () => ({ - loading: false, - crud: true, - read: true, - }), -})); - jest.mock('../../../../../common/lib/kibana', () => ({ useKibana: () => ({ services: { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index d20c62348f07f..404127893b11c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -24,13 +24,6 @@ import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin' jest.mock('../../../../../common/hooks/use_experimental_features'); const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; -jest.mock('@kbn/alerts', () => ({ - useGetUserAlertsPermissions: () => ({ - loading: false, - crud: true, - read: true, - }), -})); jest.mock('../../../../../common/hooks/use_selector'); jest.mock('../../../../../common/lib/kibana', () => ({ useKibana: () => ({ diff --git a/x-pack/plugins/security_solution/server/features.ts b/x-pack/plugins/security_solution/server/features.ts index 5494270d9ad81..cff1e2482a1ee 100644 --- a/x-pack/plugins/security_solution/server/features.ts +++ b/x-pack/plugins/security_solution/server/features.ts @@ -114,7 +114,7 @@ export const getKibanaPrivilegesFeaturePrivileges = (ruleTypes: string[]): Kiban }, alerting: ruleTypes, cases: [APP_ID], - subFeatures: [{ ...CASES_SUB_FEATURE }, { ...getAlertsSubFeature(ruleTypes) }], + subFeatures: [{ ...CASES_SUB_FEATURE } /* , { ...getAlertsSubFeature(ruleTypes) } */], privileges: { all: { app: [APP_ID, 'kibana'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap index 833a9084fdac6..3c065ab0ac109 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap @@ -6,11 +6,6 @@ Object { "test-index-*", ], "template": Object { - "aliases": Object { - ".alerts-security.alerts-space-id": Object { - "is_write_index": false, - }, - }, "mappings": Object { "_meta": Object { "aliases_version": 1, @@ -1810,10 +1805,6 @@ Object { "path": "signal.rule.building_block_type", "type": "alias", }, - "kibana.alert.rule.consumer": Object { - "type": "constant_keyword", - "value": "siem", - }, "kibana.alert.rule.created_at": Object { "path": "signal.rule.created_at", "type": "alias", @@ -1870,10 +1861,6 @@ Object { "path": "signal.rule.note", "type": "alias", }, - "kibana.alert.rule.producer": Object { - "type": "constant_keyword", - "value": "siem", - }, "kibana.alert.rule.query": Object { "path": "signal.rule.query", "type": "alias", @@ -1902,10 +1889,6 @@ Object { "path": "signal.rule.rule_name_override", "type": "alias", }, - "kibana.alert.rule.rule_type_id": Object { - "type": "constant_keyword", - "value": "siem.signals", - }, "kibana.alert.rule.saved_id": Object { "path": "signal.rule.saved_id", "type": "alias", @@ -2070,10 +2053,6 @@ Object { "path": "signal.status", "type": "alias", }, - "kibana.space_ids": Object { - "type": "constant_keyword", - "value": "space-id", - }, "labels": Object { "type": "object", }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts index ab7ff26d9d875..d65a1ad87b41a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts @@ -25,7 +25,6 @@ import { buildSiemResponse } from '../utils'; import { createSignalsFieldAliases, getSignalsTemplate, - getRbacRequiredFields, SIGNALS_TEMPLATE_VERSION, SIGNALS_FIELD_ALIASES_VERSION, ALIAS_VERSION_FIELD, @@ -89,7 +88,7 @@ export const createDetectionIndex = async ( ruleDataService: RuleDataPluginService, ruleRegistryEnabled: boolean ): Promise => { - const esClient = context.core.elasticsearch.client.asInternalUser; + const esClient = context.core.elasticsearch.client.asCurrentUser; const spaceId = siemClient.getSpaceId(); if (!siemClient) { @@ -132,11 +131,11 @@ export const createDetectionIndex = async ( // for BOTH the index AND alias name. However, through 7.14 admins only needed permissions for .siem-signals (the index) // and not .alerts-security.alerts (the alias). From the security solution perspective, all .siem-signals--* // indices should have an alias to .alerts-security.alerts- so it's safe to add those aliases as the internal user. - await addIndexAliases({ - esClient: context.core.elasticsearch.client.asInternalUser, - index, - aadIndexAliasName, - }); + // await addIndexAliases({ + // esClient: context.core.elasticsearch.client.asInternalUser, + // index, + // aadIndexAliasName, + // }); const indexVersion = await getIndexVersion(esClient, index); if (isOutdated({ current: indexVersion, target: SIGNALS_TEMPLATE_VERSION })) { await esClient.indices.rollover({ alias: index }); @@ -166,7 +165,7 @@ const addFieldAliasesToIndices = async ({ properties: { ...signalExtraFields, ...fieldAliases, - ...getRbacRequiredFields(spaceId), + // ...getRbacRequiredFields(spaceId), }, _meta: { version: currentVersion, @@ -181,26 +180,26 @@ const addFieldAliasesToIndices = async ({ } }; -const addIndexAliases = async ({ - esClient, - index, - aadIndexAliasName, -}: { - esClient: ElasticsearchClient; - index: string; - aadIndexAliasName: string; -}) => { - const { body: indices } = await esClient.indices.getAlias({ name: index }); - const aliasActions = { - actions: Object.keys(indices).map((concreteIndexName) => { - return { - add: { - index: concreteIndexName, - alias: aadIndexAliasName, - is_write_index: false, - }, - }; - }), - }; - await esClient.indices.updateAliases({ body: aliasActions }); -}; +// const addIndexAliases = async ({ +// esClient, +// index, +// aadIndexAliasName, +// }: { +// esClient: ElasticsearchClient; +// index: string; +// aadIndexAliasName: string; +// }) => { +// const { body: indices } = await esClient.indices.getAlias({ name: index }); +// const aliasActions = { +// actions: Object.keys(indices).map((concreteIndexName) => { +// return { +// add: { +// index: concreteIndexName, +// alias: aadIndexAliasName, +// is_write_index: false, +// }, +// }; +// }), +// }; +// await esClient.indices.updateAliases({ body: aliasActions }); +// }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.test.ts index 3355b0659f284..bb67dd1fca6df 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.test.ts @@ -107,12 +107,13 @@ describe('get_signals_template', () => { } }, []); const constantKeywordsFound = recursiveConstantKeywordFound('', template); - expect(constantKeywordsFound).toEqual([ - 'template.mappings.properties.kibana.space_ids', - 'template.mappings.properties.kibana.alert.rule.consumer', - 'template.mappings.properties.kibana.alert.rule.producer', - 'template.mappings.properties.kibana.alert.rule.rule_type_id', - ]); + expect(constantKeywordsFound).toEqual([]); + // expect(constantKeywordsFound).toEqual([ + // 'template.mappings.properties.kibana.space_ids', + // 'template.mappings.properties.kibana.alert.rule.consumer', + // 'template.mappings.properties.kibana.alert.rule.producer', + // 'template.mappings.properties.kibana.alert.rule.rule_type_id', + // ]); }); test('it should match snapshot', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts index 38a3612e5861d..3470f955dbdba 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts @@ -48,11 +48,11 @@ export const getSignalsTemplate = (index: string, spaceId: string, aadIndexAlias const template = { index_patterns: [`${index}-*`], template: { - aliases: { - [aadIndexAliasName]: { - is_write_index: false, - }, - }, + // aliases: { + // [aadIndexAliasName]: { + // is_write_index: false, + // }, + // }, settings: { index: { lifecycle: { @@ -72,7 +72,7 @@ export const getSignalsTemplate = (index: string, spaceId: string, aadIndexAlias ...ecsMapping.mappings.properties, ...otherMapping.mappings.properties, ...fieldAliases, - ...getRbacRequiredFields(spaceId), + // ...getRbacRequiredFields(spaceId), signal: signalsMapping.mappings.properties.signal, }, _meta: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts index c36dade4bb9d0..4cfedd5dcaa01 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts @@ -30,7 +30,7 @@ export const readIndexRoute = (router: SecuritySolutionPluginRouter, config: Con const siemResponse = buildSiemResponse(response); try { - const esClient = context.core.elasticsearch.client.asInternalUser; + const esClient = context.core.elasticsearch.client.asCurrentUser; const siemClient = context.securitySolution?.getAppClient(); if (!siemClient) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index 53bebf340c267..d3193900859fa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -128,208 +128,225 @@ export const importRulesRoute = ( const batchParseObjects = chunkParseObjects.shift() ?? []; const newImportRuleResponse = await Promise.all( batchParseObjects.reduce>>((accum, parsedRule) => { - const importsWorkerPromise = new Promise(async (resolve) => { - if (parsedRule instanceof Error) { - // If the JSON object had a validation or parse error then we return - // early with the error and an (unknown) for the ruleId - resolve( - createBulkErrorObject({ - statusCode: 400, - message: parsedRule.message, - }) - ); - return null; - } - const { - anomaly_threshold: anomalyThreshold, - author, - building_block_type: buildingBlockType, - description, - enabled, - event_category_override: eventCategoryOverride, - false_positives: falsePositives, - from, - immutable, - query: queryOrUndefined, - language: languageOrUndefined, - license, - machine_learning_job_id: machineLearningJobId, - output_index: outputIndex, - saved_id: savedId, - meta, - filters: filtersRest, - rule_id: ruleId, - index, - interval, - max_signals: maxSignals, - risk_score: riskScore, - risk_score_mapping: riskScoreMapping, - rule_name_override: ruleNameOverride, - name, - severity, - severity_mapping: severityMapping, - tags, - threat, - threat_filters: threatFilters, - threat_index: threatIndex, - threat_query: threatQuery, - threat_mapping: threatMapping, - threat_language: threatLanguage, - threat_indicator_path: threatIndicatorPath, - concurrent_searches: concurrentSearches, - items_per_search: itemsPerSearch, - threshold, - timestamp_override: timestampOverride, - to, - type, - references, - note, - timeline_id: timelineId, - timeline_title: timelineTitle, - throttle, - version, - exceptions_list: exceptionsList, - } = parsedRule; - - try { - const query = !isMlRule(type) && queryOrUndefined == null ? '' : queryOrUndefined; - - const language = - !isMlRule(type) && languageOrUndefined == null ? 'kuery' : languageOrUndefined; - - // TODO: Fix these either with an is conversion or by better typing them within io-ts - const filters: PartialFilter[] | undefined = filtersRest as PartialFilter[]; + const importsWorkerPromise = new Promise( + async (resolve, reject) => { + try { + if (parsedRule instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + resolve( + createBulkErrorObject({ + statusCode: 400, + message: parsedRule.message, + }) + ); + return null; + } - throwHttpError(await mlAuthz.validateRuleType(type)); - - const rule = await readRules({ rulesClient, ruleId, id: undefined }); - if (rule == null) { - await createRules({ - rulesClient, - anomalyThreshold, + const { + anomaly_threshold: anomalyThreshold, author, - buildingBlockType, + building_block_type: buildingBlockType, description, enabled, - eventCategoryOverride, - falsePositives, + event_category_override: eventCategoryOverride, + false_positives: falsePositives, from, immutable, - query, - language, + query: queryOrUndefined, + language: languageOrUndefined, license, - machineLearningJobId, - outputIndex: signalsIndex, - savedId, - timelineId, - timelineTitle, + machine_learning_job_id: machineLearningJobId, + output_index: outputIndex, + saved_id: savedId, meta, - filters, - ruleId, + filters: filtersRest, + rule_id: ruleId, index, interval, - maxSignals, + max_signals: maxSignals, + risk_score: riskScore, + risk_score_mapping: riskScoreMapping, + rule_name_override: ruleNameOverride, name, - riskScore, - riskScoreMapping, - ruleNameOverride, severity, - severityMapping, + severity_mapping: severityMapping, tags, - throttle, - to, - type, threat, + threat_filters: threatFilters, + threat_index: threatIndex, + threat_query: threatQuery, + threat_mapping: threatMapping, + threat_language: threatLanguage, + threat_indicator_path: threatIndicatorPath, + concurrent_searches: concurrentSearches, + items_per_search: itemsPerSearch, threshold, - threatFilters, - threatIndex, - threatIndicatorPath, - threatQuery, - threatMapping, - threatLanguage, - concurrentSearches, - itemsPerSearch, - timestampOverride, - references, - note, - version, - exceptionsList, - actions: [], // Actions are not imported nor exported at this time - }); - resolve({ rule_id: ruleId, status_code: 200 }); - } else if (rule != null && request.query.overwrite) { - await patchRules({ - rulesClient, - author, - buildingBlockType, - spaceId: context.securitySolution.getSpaceId(), - ruleStatusClient, - description, - enabled, - eventCategoryOverride, - falsePositives, - from, - query, - language, - license, - outputIndex, - savedId, - timelineId, - timelineTitle, - meta, - filters, - rule, - index, - interval, - maxSignals, - riskScore, - riskScoreMapping, - ruleNameOverride, - name, - severity, - severityMapping, - tags, - timestampOverride, - throttle, + timestamp_override: timestampOverride, to, type, - threat, - threshold, - threatFilters, - threatIndex, - threatQuery, - threatMapping, - threatLanguage, - concurrentSearches, - itemsPerSearch, references, note, + timeline_id: timelineId, + timeline_title: timelineTitle, + throttle, version, - exceptionsList, - anomalyThreshold, - machineLearningJobId, - actions: undefined, - }); - resolve({ rule_id: ruleId, status_code: 200 }); - } else if (rule != null) { - resolve( - createBulkErrorObject({ + exceptions_list: exceptionsList, + } = parsedRule; + + try { + const query = + !isMlRule(type) && queryOrUndefined == null ? '' : queryOrUndefined; + const language = + !isMlRule(type) && languageOrUndefined == null + ? 'kuery' + : languageOrUndefined; // TODO: Fix these either with an is conversion or by better typing them within io-ts + + const filters: PartialFilter[] | undefined = filtersRest as PartialFilter[]; + throwHttpError(await mlAuthz.validateRuleType(type)); + const rule = await readRules({ + rulesClient, ruleId, - statusCode: 409, - message: `rule_id: "${ruleId}" already exists`, - }) - ); + id: undefined, + }); + + if (rule == null) { + await createRules({ + rulesClient, + anomalyThreshold, + author, + buildingBlockType, + description, + enabled, + eventCategoryOverride, + falsePositives, + from, + immutable, + query, + language, + license, + machineLearningJobId, + outputIndex: signalsIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + ruleId, + index, + interval, + maxSignals, + name, + riskScore, + riskScoreMapping, + ruleNameOverride, + severity, + severityMapping, + tags, + throttle, + to, + type, + threat, + threshold, + threatFilters, + threatIndex, + threatIndicatorPath, + threatQuery, + threatMapping, + threatLanguage, + concurrentSearches, + itemsPerSearch, + timestampOverride, + references, + note, + version, + exceptionsList, + actions: [], // Actions are not imported nor exported at this time + }); + resolve({ + rule_id: ruleId, + status_code: 200, + }); + } else if (rule != null && request.query.overwrite) { + await patchRules({ + rulesClient, + author, + buildingBlockType, + spaceId: context.securitySolution.getSpaceId(), + ruleStatusClient, + description, + enabled, + eventCategoryOverride, + falsePositives, + from, + query, + language, + license, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + rule, + index, + interval, + maxSignals, + riskScore, + riskScoreMapping, + ruleNameOverride, + name, + severity, + severityMapping, + tags, + timestampOverride, + throttle, + to, + type, + threat, + threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, + concurrentSearches, + itemsPerSearch, + references, + note, + version, + exceptionsList, + anomalyThreshold, + machineLearningJobId, + actions: undefined, + }); + resolve({ + rule_id: ruleId, + status_code: 200, + }); + } else if (rule != null) { + resolve( + createBulkErrorObject({ + ruleId, + statusCode: 409, + message: `rule_id: "${ruleId}" already exists`, + }) + ); + } + } catch (err) { + resolve( + createBulkErrorObject({ + ruleId, + statusCode: err.statusCode ?? 400, + message: err.message, + }) + ); + } + } catch (error) { + reject(error); } - } catch (err) { - resolve( - createBulkErrorObject({ - ruleId, - statusCode: err.statusCode ?? 400, - message: err.message, - }) - ); } - }); + ); return [...accum, importsWorkerPromise]; }, []) ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts index bf21f9de037f4..e54cc94b886f6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts @@ -6,6 +6,7 @@ */ import { transformError } from '@kbn/securitysolution-es-utils'; +import { ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils'; import { setSignalStatusValidateTypeDependents } from '../../../../../common/detection_engine/schemas/request/set_signal_status_type_dependents'; import { SetSignalsStatusSchemaDecoded, @@ -66,7 +67,12 @@ export const setSignalsStatusRoute = (router: SecuritySolutionPluginRouter) => { refresh: true, body: { script: { - source: `ctx._source.signal.status = '${status}'`, + source: `if (ctx._source['${ALERT_WORKFLOW_STATUS}'] != null) { + ctx._source['${ALERT_WORKFLOW_STATUS}'] = '${status}' + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = '${status}' + }`, lang: 'painless', }, query: queryObject, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts index d56344b7707db..1b867507905a7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts @@ -10,6 +10,7 @@ import { v4 } from 'uuid'; import { Logger, SavedObject } from 'kibana/server'; import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { mlPluginServerMock } from '../../../../../../ml/server/mocks'; import type { IRuleDataClient } from '../../../../../../rule_registry/server'; import { ruleRegistryMocks } from '../../../../../../rule_registry/server/mocks'; @@ -84,6 +85,7 @@ export const createRuleTypeMocks = ( config$: mockedConfig$, lists: listMock.createSetup(), logger: loggerMock, + ml: mlPluginServerMock.createSetupContract(), ruleDataClient: ruleRegistryMocks.createRuleDataClient( '.alerts-security.alerts' ) as IRuleDataClient, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/index.ts index 75252cc3d47ae..0fde90c991e40 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/index.ts @@ -7,3 +7,4 @@ export { createQueryAlertType } from './query/create_query_alert_type'; export { createIndicatorMatchAlertType } from './indicator_match/create_indicator_match_alert_type'; +export { createMlAlertType } from './ml/create_ml_alert_type'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts new file mode 100644 index 0000000000000..40566ffa04e6a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts @@ -0,0 +1,112 @@ +/* + * 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 { mlPluginServerMock } from '../../../../../../ml/server/mocks'; + +import { allowedExperimentalValues } from '../../../../../common/experimental_features'; +import { bulkCreateMlSignals } from '../../signals/bulk_create_ml_signals'; + +import { createRuleTypeMocks } from '../__mocks__/rule_type'; +import { createMlAlertType } from './create_ml_alert_type'; + +import { RuleParams } from '../../schemas/rule_schemas'; + +jest.mock('../../signals/bulk_create_ml_signals'); + +jest.mock('../utils/get_list_client', () => ({ + getListClient: jest.fn().mockReturnValue({ + listClient: { + getListItemIndex: jest.fn(), + }, + exceptionsClient: jest.fn(), + }), +})); + +jest.mock('../../rule_execution_log/rule_execution_log_client'); + +jest.mock('../../signals/filters/filter_events_against_list', () => ({ + filterEventsAgainstList: jest.fn().mockReturnValue({ + _shards: { + failures: [], + }, + hits: { + hits: [ + { + is_interim: false, + }, + ], + }, + }), +})); + +let jobsSummaryMock: jest.Mock; +let mlMock: ReturnType; + +describe('Machine Learning Alerts', () => { + beforeEach(() => { + jobsSummaryMock = jest.fn(); + jobsSummaryMock.mockResolvedValue([ + { + id: 'test-ml-job', + jobState: 'started', + datafeedState: 'started', + }, + ]); + mlMock = mlPluginServerMock.createSetupContract(); + mlMock.jobServiceProvider.mockReturnValue({ + jobsSummary: jobsSummaryMock, + }); + + (bulkCreateMlSignals as jest.Mock).mockResolvedValue({ + success: true, + bulkCreateDuration: 0, + createdItemsCount: 1, + createdItems: [ + { + _id: '897234565234', + _index: 'test-index', + anomalyScore: 23, + }, + ], + errors: [], + }); + }); + + const params: Partial = { + anomalyThreshold: 23, + from: 'now-45m', + machineLearningJobId: ['test-ml-job'], + to: 'now', + type: 'machine_learning', + }; + + it('does not send an alert when no anomalies found', async () => { + jobsSummaryMock.mockResolvedValue([ + { + id: 'test-ml-job', + jobState: 'started', + datafeedState: 'started', + }, + ]); + const { dependencies, executor } = createRuleTypeMocks('machine_learning', params); + const mlAlertType = createMlAlertType({ + experimentalFeatures: allowedExperimentalValues, + lists: dependencies.lists, + logger: dependencies.logger, + mergeStrategy: 'allFields', + ml: mlMock, + ruleDataClient: dependencies.ruleDataClient, + ruleDataService: dependencies.ruleDataService, + version: '1.0.0', + }); + + dependencies.alerting.registerType(mlAlertType); + + await executor({ params }); + expect(dependencies.ruleDataClient.getWriter).not.toBeCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts new file mode 100644 index 0000000000000..1d872df35de3a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { validateNonExact } from '@kbn/securitysolution-io-ts-utils'; +import { PersistenceServices } from '../../../../../../rule_registry/server'; +import { ML_ALERT_TYPE_ID } from '../../../../../common/constants'; +import { machineLearningRuleParams, MachineLearningRuleParams } from '../../schemas/rule_schemas'; +import { mlExecutor } from '../../signals/executors/ml'; +import { createSecurityRuleTypeFactory } from '../create_security_rule_type_factory'; +import { CreateRuleOptions } from '../types'; + +export const createMlAlertType = (createOptions: CreateRuleOptions) => { + const { lists, logger, mergeStrategy, ml, ruleDataClient, ruleDataService } = createOptions; + const createSecurityRuleType = createSecurityRuleTypeFactory({ + lists, + logger, + mergeStrategy, + ruleDataClient, + ruleDataService, + }); + return createSecurityRuleType({ + id: ML_ALERT_TYPE_ID, + name: 'Machine Learning Rule', + validate: { + params: { + validate: (object: unknown) => { + const [validated, errors] = validateNonExact(object, machineLearningRuleParams); + if (errors != null) { + throw new Error(errors); + } + if (validated == null) { + throw new Error('Validation of rule params failed'); + } + return validated; + }, + }, + }, + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + actionVariables: { + context: [{ name: 'server', description: 'the server' }], + }, + minimumLicenseRequired: 'basic', + isExportable: false, + producer: 'security-solution', + async executor(execOptions) { + const { + runOpts: { + buildRuleMessage, + bulkCreate, + exceptionItems, + listClient, + rule, + tuple, + wrapHits, + }, + services, + state, + } = execOptions; + + const result = await mlExecutor({ + buildRuleMessage, + bulkCreate, + exceptionItems, + listClient, + logger, + ml, + rule, + services, + tuple, + wrapHits, + }); + return { ...result, state }; + }, + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/scripts/create_rule_indicator_match.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/scripts/create_rule_indicator_match.sh old mode 100644 new mode 100755 diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/scripts/create_rule_ml.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/scripts/create_rule_ml.sh new file mode 100755 index 0000000000000..8ce3d56cd0170 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/scripts/create_rule_ml.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# +# 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. +# + +curl -X POST ${KIBANA_URL}${SPACE_URL}/api/alerts/alert \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -H 'kbn-xsrf: true' \ + -H 'Content-Type: application/json' \ + --verbose \ + -d ' +{ + "params":{ + "anomalyThreshold": 23, + "author": [], + "description": "Basic Machine Learning Rule", + "exceptionsList": [], + "falsePositives": [], + "from": "now-45m", + "immutable": false, + "machineLearningJobId": ["test-ml-job"], + "maxSignals": 101, + "outputIndex": "", + "references": [], + "riskScore": 23, + "riskScoreMapping": [], + "ruleId": "1781d055-5c66-4adf-9c59-fc0fa58336a5", + "severity": "high", + "severityMapping": [], + "threat": [], + "to": "now", + "type": "machine_learning", + "version": 1 + }, + "consumer":"alerts", + "alertTypeId":"siem.mlRule", + "schedule":{ + "interval":"15m" + }, + "actions":[], + "tags":[ + "custom", + "ml", + "persistence" + ], + "notifyWhen":"onActionGroupChange", + "name":"Basic Machine Learning Rule" +}' + + diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts index e781bfc50bee4..f061240c4a6e5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts @@ -99,7 +99,7 @@ export type CreateSecurityRuleTypeFactory = (options: { ruleDataClient: IRuleDataClient; ruleDataService: IRuleDataPluginService; }) => < - TParams extends RuleParams & { index: string[] | undefined }, + TParams extends RuleParams & { index?: string[] | undefined }, TAlertInstanceContext extends AlertInstanceContext, TServices extends PersistenceServices, TState extends AlertTypeState @@ -124,6 +124,7 @@ export interface CreateRuleOptions { lists: SetupPlugins['lists']; logger: Logger; mergeStrategy: ConfigType['alertMergeStrategy']; + ml?: SetupPlugins['ml']; ruleDataClient: IRuleDataClient; version: string; ruleDataService: IRuleDataPluginService; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/helpers.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/helpers.ts index 70d93d7552b1c..7e35c2163df70 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/helpers.ts @@ -106,133 +106,132 @@ export const importTimelines = async ( batchParseObjects.reduce>>((accum, parsedTimeline) => { const importsWorkerPromise = new Promise( async (resolve, reject) => { - if (parsedTimeline instanceof Error) { - // If the JSON object had a validation or parse error then we return - // early with the error and an (unknown) for the ruleId - resolve( - createBulkErrorObject({ - statusCode: 400, - message: parsedTimeline.message, - }) - ); - - return null; - } - - const { - savedObjectId, - pinnedEventIds, - globalNotes, - eventNotes, - status, - templateTimelineId, - templateTimelineVersion, - title, - timelineType, - version, - } = parsedTimeline; - - const parsedTimelineObject = omit(timelineSavedObjectOmittedFields, parsedTimeline); - let newTimeline = null; try { - const compareTimelinesStatus = new CompareTimelinesStatus({ + if (parsedTimeline instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + resolve( + createBulkErrorObject({ + statusCode: 400, + message: parsedTimeline.message, + }) + ); + return null; + } + + const { + savedObjectId, + pinnedEventIds, + globalNotes, + eventNotes, status, - timelineType, + templateTimelineId, + templateTimelineVersion, title, - timelineInput: { - id: savedObjectId, - version, - }, - templateTimelineInput: { - id: templateTimelineId, - version: templateTimelineVersion, - }, - frameworkRequest, - }); - await compareTimelinesStatus.init(); - const isTemplateTimeline = compareTimelinesStatus.isHandlingTemplateTimeline; - if (compareTimelinesStatus.isCreatableViaImport) { - // create timeline / timeline template - newTimeline = await createTimelines({ - frameworkRequest, - timeline: setTimeline(parsedTimelineObject, parsedTimeline, isTemplateTimeline), - pinnedEventIds: isTemplateTimeline ? null : pinnedEventIds, - notes: isTemplateTimeline ? globalNotes : [...globalNotes, ...eventNotes], - isImmutable, - overrideNotesOwner: false, - }); + timelineType, + version, + } = parsedTimeline; + const parsedTimelineObject = omit(timelineSavedObjectOmittedFields, parsedTimeline); + let newTimeline = null; - resolve({ - timeline_id: newTimeline.timeline.savedObjectId, - status_code: 200, - action: TimelineStatusActions.createViaImport, + try { + const compareTimelinesStatus = new CompareTimelinesStatus({ + status, + timelineType, + title, + timelineInput: { + id: savedObjectId, + version, + }, + templateTimelineInput: { + id: templateTimelineId, + version: templateTimelineVersion, + }, + frameworkRequest, }); - } + await compareTimelinesStatus.init(); + const isTemplateTimeline = compareTimelinesStatus.isHandlingTemplateTimeline; - if (!compareTimelinesStatus.isHandlingTemplateTimeline) { - const errorMessage = compareTimelinesStatus.checkIsFailureCases( - TimelineStatusActions.createViaImport - ); - const message = errorMessage?.body ?? DEFAULT_ERROR; - - resolve( - createBulkErrorObject({ - id: savedObjectId ?? 'unknown', - statusCode: 409, - message, - }) - ); - } else { - if (compareTimelinesStatus.isUpdatableViaImport) { - // update timeline template + if (compareTimelinesStatus.isCreatableViaImport) { + // create timeline / timeline template newTimeline = await createTimelines({ frameworkRequest, - timeline: parsedTimelineObject, - timelineSavedObjectId: compareTimelinesStatus.timelineId, - timelineVersion: compareTimelinesStatus.timelineVersion, - notes: globalNotes, - existingNoteIds: compareTimelinesStatus.timelineInput.data?.noteIds, + timeline: setTimeline(parsedTimelineObject, parsedTimeline, isTemplateTimeline), + pinnedEventIds: isTemplateTimeline ? null : pinnedEventIds, + notes: isTemplateTimeline ? globalNotes : [...globalNotes, ...eventNotes], isImmutable, overrideNotesOwner: false, }); - resolve({ timeline_id: newTimeline.timeline.savedObjectId, status_code: 200, - action: TimelineStatusActions.updateViaImport, + action: TimelineStatusActions.createViaImport, }); - } else { + } + + if (!compareTimelinesStatus.isHandlingTemplateTimeline) { const errorMessage = compareTimelinesStatus.checkIsFailureCases( - TimelineStatusActions.updateViaImport + TimelineStatusActions.createViaImport ); - const message = errorMessage?.body ?? DEFAULT_ERROR; - resolve( createBulkErrorObject({ - id: - savedObjectId ?? - (templateTimelineId - ? `(template_timeline_id) ${templateTimelineId}` - : 'unknown'), + id: savedObjectId ?? 'unknown', statusCode: 409, message, }) ); + } else { + if (compareTimelinesStatus.isUpdatableViaImport) { + // update timeline template + newTimeline = await createTimelines({ + frameworkRequest, + timeline: parsedTimelineObject, + timelineSavedObjectId: compareTimelinesStatus.timelineId, + timelineVersion: compareTimelinesStatus.timelineVersion, + notes: globalNotes, + existingNoteIds: compareTimelinesStatus.timelineInput.data?.noteIds, + isImmutable, + overrideNotesOwner: false, + }); + resolve({ + timeline_id: newTimeline.timeline.savedObjectId, + status_code: 200, + action: TimelineStatusActions.updateViaImport, + }); + } else { + const errorMessage = compareTimelinesStatus.checkIsFailureCases( + TimelineStatusActions.updateViaImport + ); + const message = errorMessage?.body ?? DEFAULT_ERROR; + resolve( + createBulkErrorObject({ + id: + savedObjectId ?? + (templateTimelineId + ? `(template_timeline_id) ${templateTimelineId}` + : 'unknown'), + statusCode: 409, + message, + }) + ); + } } + } catch (err) { + resolve( + createBulkErrorObject({ + id: + savedObjectId ?? + (templateTimelineId + ? `(template_timeline_id) ${templateTimelineId}` + : 'unknown'), + statusCode: 400, + message: err.message, + }) + ); } - } catch (err) { - resolve( - createBulkErrorObject({ - id: - savedObjectId ?? - (templateTimelineId - ? `(template_timeline_id) ${templateTimelineId}` - : 'unknown'), - statusCode: 400, - message: err.message, - }) - ); + } catch (error) { + reject(error); } } ); diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 734ccc4d5ba8c..040ebb659abce 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -49,6 +49,7 @@ import { ILicense, LicensingPluginStart } from '../../licensing/server'; import { FleetStartContract } from '../../fleet/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; import { createQueryAlertType } from './lib/detection_engine/rule_types'; +import { createMlAlertType } from './lib/detection_engine/rule_types/ml/create_ml_alert_type'; import { initRoutes } from './routes'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type'; @@ -65,6 +66,7 @@ import { QUERY_ALERT_TYPE_ID, DEFAULT_SPACE_ID, INDICATOR_ALERT_TYPE_ID, + ML_ALERT_TYPE_ID, } from '../common/constants'; import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; @@ -129,6 +131,7 @@ export interface PluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PluginStart {} + export class Plugin implements IPlugin { private readonly logger: Logger; private readonly config: ConfigType; @@ -246,6 +249,7 @@ export class Plugin implements IPlugin { }; return new Promise(async (resolve, reject) => { - createConfigurationAggregator(configuration, managedConfig) - .pipe(take(3), bufferCount(3)) - .subscribe(([initial, updatedWorkers, updatedInterval]) => { - expect(initial.value).toEqual({ - max_workers: 10, - poll_interval: 6000000, - max_poll_inactivity_cycles: 10, - request_capacity: 1000, - monitored_aggregated_stats_refresh_rate: 5000, - monitored_stats_running_average_window: 50, - monitored_task_execution_thresholds: { - default: { - error_threshold: 90, - warn_threshold: 80, + try { + createConfigurationAggregator(configuration, managedConfig) + .pipe(take(3), bufferCount(3)) + .subscribe(([initial, updatedWorkers, updatedInterval]) => { + expect(initial.value).toEqual({ + max_workers: 10, + poll_interval: 6000000, + max_poll_inactivity_cycles: 10, + request_capacity: 1000, + monitored_aggregated_stats_refresh_rate: 5000, + monitored_stats_running_average_window: 50, + monitored_task_execution_thresholds: { + default: { + error_threshold: 90, + warn_threshold: 80, + }, + custom: {}, }, - custom: {}, - }, - }); - - expect(updatedWorkers.value).toEqual({ - max_workers: 8, - poll_interval: 6000000, - max_poll_inactivity_cycles: 10, - request_capacity: 1000, - monitored_aggregated_stats_refresh_rate: 5000, - monitored_stats_running_average_window: 50, - monitored_task_execution_thresholds: { - default: { - error_threshold: 90, - warn_threshold: 80, + }); + expect(updatedWorkers.value).toEqual({ + max_workers: 8, + poll_interval: 6000000, + max_poll_inactivity_cycles: 10, + request_capacity: 1000, + monitored_aggregated_stats_refresh_rate: 5000, + monitored_stats_running_average_window: 50, + monitored_task_execution_thresholds: { + default: { + error_threshold: 90, + warn_threshold: 80, + }, + custom: {}, }, - custom: {}, - }, - }); - - expect(updatedInterval.value).toEqual({ - max_workers: 8, - poll_interval: 3000, - max_poll_inactivity_cycles: 10, - request_capacity: 1000, - monitored_aggregated_stats_refresh_rate: 5000, - monitored_stats_running_average_window: 50, - monitored_task_execution_thresholds: { - default: { - error_threshold: 90, - warn_threshold: 80, + }); + expect(updatedInterval.value).toEqual({ + max_workers: 8, + poll_interval: 3000, + max_poll_inactivity_cycles: 10, + request_capacity: 1000, + monitored_aggregated_stats_refresh_rate: 5000, + monitored_stats_running_average_window: 50, + monitored_task_execution_thresholds: { + default: { + error_threshold: 90, + warn_threshold: 80, + }, + custom: {}, }, - custom: {}, - }, - }); - resolve(); - }, reject); - - managedConfig.maxWorkersConfiguration$.next(8); - - managedConfig.pollIntervalConfiguration$.next(3000); + }); + resolve(); + }, reject); + managedConfig.maxWorkersConfiguration$.next(8); + managedConfig.pollIntervalConfiguration$.next(3000); + } catch (error) { + reject(error); + } }); }); }); diff --git a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts index 9125bca8f5b05..d24931646128a 100644 --- a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts @@ -328,27 +328,44 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise(async (resolve) => { - workloadAggregator.pipe(first()).subscribe((result) => { - expect(result.key).toEqual('workload'); - expect(result.value).toMatchObject({ - count: 4, - task_types: { - actions_telemetry: { count: 2, status: { idle: 2 } }, - alerting_telemetry: { count: 1, status: { idle: 1 } }, - session_cleanup: { count: 1, status: { idle: 1 } }, - }, + return new Promise(async (resolve, reject) => { + try { + workloadAggregator.pipe(first()).subscribe((result) => { + expect(result.key).toEqual('workload'); + expect(result.value).toMatchObject({ + count: 4, + task_types: { + actions_telemetry: { + count: 2, + status: { + idle: 2, + }, + }, + alerting_telemetry: { + count: 1, + status: { + idle: 1, + }, + }, + session_cleanup: { + count: 1, + status: { + idle: 1, + }, + }, + }, + }); + resolve(); }); - resolve(); - }); - - availability$.next(false); - - await sleep(10); - expect(taskStore.aggregate).not.toHaveBeenCalled(); - await sleep(10); - expect(taskStore.aggregate).not.toHaveBeenCalled(); - availability$.next(true); + availability$.next(false); + await sleep(10); + expect(taskStore.aggregate).not.toHaveBeenCalled(); + await sleep(10); + expect(taskStore.aggregate).not.toHaveBeenCalled(); + availability$.next(true); + } catch (error) { + reject(error); + } }); }); diff --git a/x-pack/plugins/task_manager/server/saved_objects/index.ts b/x-pack/plugins/task_manager/server/saved_objects/index.ts index d2d079c7747b1..abbd1af73b55a 100644 --- a/x-pack/plugins/task_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/task_manager/server/saved_objects/index.ts @@ -8,7 +8,7 @@ import type { SavedObjectsServiceSetup, SavedObjectsTypeMappingDefinition } from 'kibana/server'; import { estypes } from '@elastic/elasticsearch'; import mappings from './mappings.json'; -import { migrations } from './migrations'; +import { getMigrations } from './migrations'; import { TaskManagerConfig } from '../config.js'; import { getOldestIdleActionTask } from '../queries/oldest_idle_action_task'; @@ -22,7 +22,7 @@ export function setupSavedObjects( hidden: true, convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id; ctx._source.remove("kibana")`, mappings: mappings.task as SavedObjectsTypeMappingDefinition, - migrations, + migrations: getMigrations(), indexPattern: config.index, excludeOnUpgrade: async ({ readonlyEsClient }) => { const oldestNeededActionParams = await getOldestIdleActionTask( diff --git a/x-pack/plugins/task_manager/server/saved_objects/migrations.test.ts b/x-pack/plugins/task_manager/server/saved_objects/migrations.test.ts new file mode 100644 index 0000000000000..73141479d9081 --- /dev/null +++ b/x-pack/plugins/task_manager/server/saved_objects/migrations.test.ts @@ -0,0 +1,169 @@ +/* + * 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 uuid from 'uuid'; +import { getMigrations } from './migrations'; +import { SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { migrationMocks } from 'src/core/server/mocks'; +import { TaskInstanceWithDeprecatedFields } from '../task'; + +const migrationContext = migrationMocks.createContext(); + +describe('successful migrations', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + describe('7.4.0', () => { + test('extend task instance with updated_at', () => { + const migration740 = getMigrations()['7.4.0']; + const taskInstance = getMockData({}); + expect(migration740(taskInstance, migrationContext).attributes.updated_at).not.toBeNull(); + }); + }); + + describe('7.6.0', () => { + test('rename property Internal to Schedule', () => { + const migration760 = getMigrations()['7.6.0']; + const taskInstance = getMockData({}); + expect(migration760(taskInstance, migrationContext)).toEqual({ + ...taskInstance, + attributes: { + ...taskInstance.attributes, + schedule: taskInstance.attributes.schedule, + }, + }); + }); + }); + + describe('8.0.0', () => { + test('transforms actionsTasksLegacyIdToSavedObjectIds', () => { + const migration800 = getMigrations()['8.0.0']; + const taskInstance = getMockData({ + taskType: 'actions:123456', + params: JSON.stringify({ spaceId: 'user1', actionTaskParamsId: '123456' }), + }); + + expect(migration800(taskInstance, migrationContext)).toEqual({ + ...taskInstance, + attributes: { + ...taskInstance.attributes, + params: '{"spaceId":"user1","actionTaskParamsId":"800f81f8-980e-58ca-b710-d1b0644adea2"}', + }, + }); + }); + + test('it is only applicable for saved objects that live in a custom space', () => { + const migration800 = getMigrations()['8.0.0']; + const taskInstance = getMockData({ + taskType: 'actions:123456', + params: JSON.stringify({ spaceId: 'default', actionTaskParamsId: '123456' }), + }); + + expect(migration800(taskInstance, migrationContext)).toEqual(taskInstance); + }); + + test('it is only applicable for saved objects that live in a custom space even if spaces are disabled', () => { + const migration800 = getMigrations()['8.0.0']; + const taskInstance = getMockData({ + taskType: 'actions:123456', + params: JSON.stringify({ actionTaskParamsId: '123456' }), + }); + + expect(migration800(taskInstance, migrationContext)).toEqual(taskInstance); + }); + + test('transforms alertingTaskLegacyIdToSavedObjectIds', () => { + const migration800 = getMigrations()['8.0.0']; + const taskInstance = getMockData({ + taskType: 'alerting:123456', + params: JSON.stringify({ spaceId: 'user1', alertId: '123456' }), + }); + + expect(migration800(taskInstance, migrationContext)).toEqual({ + ...taskInstance, + attributes: { + ...taskInstance.attributes, + params: '{"spaceId":"user1","alertId":"1a4f9206-e25f-58e6-bad5-3ff21e90648e"}', + }, + }); + }); + + test('skip transformation for defult space scenario', () => { + const migration800 = getMigrations()['8.0.0']; + const taskInstance = getMockData({ + taskType: 'alerting:123456', + params: JSON.stringify({ spaceId: 'default', alertId: '123456' }), + }); + + expect(migration800(taskInstance, migrationContext)).toEqual({ + ...taskInstance, + attributes: { + ...taskInstance.attributes, + params: '{"spaceId":"default","alertId":"123456"}', + }, + }); + }); + }); +}); + +describe('handles errors during migrations', () => { + describe('8.0.0 throws if migration fails', () => { + test('should throw the exception if task instance params format is wrong', () => { + const migration800 = getMigrations()['8.0.0']; + const taskInstance = getMockData({ + taskType: 'alerting:123456', + params: `{ spaceId: 'user1', customId: '123456' }`, + }); + expect(() => { + migration800(taskInstance, migrationContext); + }).toThrowError(); + expect(migrationContext.log.error).toHaveBeenCalledWith( + `savedObject 8.0.0 migration failed for task instance ${taskInstance.id} with error: Unexpected token s in JSON at position 2`, + { + migrations: { + taskInstanceDocument: { + ...taskInstance, + attributes: { + ...taskInstance.attributes, + }, + }, + }, + } + ); + }); + }); +}); + +function getUpdatedAt(): string { + const updatedAt = new Date(); + updatedAt.setHours(updatedAt.getHours() + 2); + return updatedAt.toISOString(); +} + +function getMockData( + overwrites: Record = {} +): SavedObjectUnsanitizedDoc> { + return { + attributes: { + scheduledAt: new Date(), + state: { runs: 0, total_cleaned_up: 0 }, + runAt: new Date(), + startedAt: new Date(), + retryAt: new Date(), + ownerId: '234', + taskType: 'foo', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + ...overwrites, + }, + updated_at: getUpdatedAt(), + id: uuid.v4(), + type: 'task', + }; +} diff --git a/x-pack/plugins/task_manager/server/saved_objects/migrations.ts b/x-pack/plugins/task_manager/server/saved_objects/migrations.ts index 879fca2ae4f6f..a2ed91dba2737 100644 --- a/x-pack/plugins/task_manager/server/saved_objects/migrations.ts +++ b/x-pack/plugins/task_manager/server/saved_objects/migrations.ts @@ -5,16 +5,123 @@ * 2.0. */ -import { SavedObjectMigrationMap, SavedObjectUnsanitizedDoc } from '../../../../../src/core/server'; +import { + LogMeta, + SavedObjectMigrationContext, + SavedObjectMigrationFn, + SavedObjectMigrationMap, + SavedObjectsUtils, + SavedObjectUnsanitizedDoc, +} from '../../../../../src/core/server'; import { TaskInstance, TaskInstanceWithDeprecatedFields } from '../task'; -export const migrations: SavedObjectMigrationMap = { - '7.4.0': (doc) => ({ - ...doc, - updated_at: new Date().toISOString(), - }), - '7.6.0': moveIntervalIntoSchedule, -}; +interface TaskInstanceLogMeta extends LogMeta { + migrations: { taskInstanceDocument: SavedObjectUnsanitizedDoc }; +} + +type TaskInstanceMigration = ( + doc: SavedObjectUnsanitizedDoc +) => SavedObjectUnsanitizedDoc; + +export function getMigrations(): SavedObjectMigrationMap { + return { + '7.4.0': executeMigrationWithErrorHandling( + (doc) => ({ + ...doc, + updated_at: new Date().toISOString(), + }), + '7.4.0' + ), + '7.6.0': executeMigrationWithErrorHandling(moveIntervalIntoSchedule, '7.6.0'), + '8.0.0': executeMigrationWithErrorHandling( + pipeMigrations(alertingTaskLegacyIdToSavedObjectIds, actionsTasksLegacyIdToSavedObjectIds), + '8.0.0' + ), + }; +} + +function executeMigrationWithErrorHandling( + migrationFunc: SavedObjectMigrationFn< + TaskInstanceWithDeprecatedFields, + TaskInstanceWithDeprecatedFields + >, + version: string +) { + return ( + doc: SavedObjectUnsanitizedDoc, + context: SavedObjectMigrationContext + ) => { + try { + return migrationFunc(doc, context); + } catch (ex) { + context.log.error( + `savedObject ${version} migration failed for task instance ${doc.id} with error: ${ex.message}`, + { + migrations: { + taskInstanceDocument: doc, + }, + } + ); + throw ex; + } + }; +} + +function alertingTaskLegacyIdToSavedObjectIds( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + if (doc.attributes.taskType.startsWith('alerting:')) { + let params: { spaceId?: string; alertId?: string } = {}; + params = JSON.parse((doc.attributes.params as unknown) as string); + + if (params.alertId && params.spaceId && params.spaceId !== 'default') { + const newId = SavedObjectsUtils.getConvertedObjectId(params.spaceId, 'alert', params.alertId); + return { + ...doc, + attributes: { + ...doc.attributes, + params: JSON.stringify({ + ...params, + alertId: newId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + }, + }; + } + } + + return doc; +} + +function actionsTasksLegacyIdToSavedObjectIds( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + if (doc.attributes.taskType.startsWith('actions:')) { + let params: { spaceId?: string; actionTaskParamsId?: string } = {}; + params = JSON.parse((doc.attributes.params as unknown) as string); + + if (params.actionTaskParamsId && params.spaceId && params.spaceId !== 'default') { + const newId = SavedObjectsUtils.getConvertedObjectId( + params.spaceId, + 'action_task_params', + params.actionTaskParamsId + ); + return { + ...doc, + attributes: { + ...doc.attributes, + params: JSON.stringify({ + ...params, + actionTaskParamsId: newId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + }, + }; + } + } + + return doc; +} function moveIntervalIntoSchedule({ attributes: { interval, ...attributes }, @@ -34,3 +141,8 @@ function moveIntervalIntoSchedule({ }, }; } + +function pipeMigrations(...migrations: TaskInstanceMigration[]): TaskInstanceMigration { + return (doc: SavedObjectUnsanitizedDoc) => + migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); +} diff --git a/x-pack/plugins/task_manager/server/task_scheduling.ts b/x-pack/plugins/task_manager/server/task_scheduling.ts index 88176b25680ca..a89f66d9c772b 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.ts @@ -113,11 +113,18 @@ export class TaskScheduling { */ public async runNow(taskId: string): Promise { return new Promise(async (resolve, reject) => { - this.awaitTaskRunResult(taskId) - // don't expose state on runNow - .then(({ id }) => resolve({ id })) - .catch(reject); - this.taskPollingLifecycle.attemptToRun(taskId); + try { + this.awaitTaskRunResult(taskId) // don't expose state on runNow + .then(({ id }) => + resolve({ + id, + }) + ) + .catch(reject); + this.taskPollingLifecycle.attemptToRun(taskId); + } catch (error) { + reject(error); + } }); } @@ -137,39 +144,42 @@ export class TaskScheduling { taskInstance: task, }); return new Promise(async (resolve, reject) => { - // The actual promise returned from this function is resolved after the awaitTaskRunResult promise resolves. - // However, we do not wait to await this promise, as we want later execution to happen in parallel. - // The awaitTaskRunResult promise is resolved once the ephemeral task is successfully executed (technically, when a TaskEventType.TASK_RUN is emitted with the same id). - // However, the ephemeral task won't even get into the queue until the subsequent this.ephemeralTaskLifecycle.attemptToRun is called (which puts it in the queue). - - // The reason for all this confusion? Timing. - - // In the this.ephemeralTaskLifecycle.attemptToRun, it's possible that the ephemeral task is put into the queue and processed before this function call returns anything. - // If that happens, putting the awaitTaskRunResult after would just hang because the task already completed. We need to listen for the completion before we add it to the queue to avoid this possibility. - const { cancel, resolveOnCancel } = cancellablePromise(); - this.awaitTaskRunResult(id, resolveOnCancel) - .then((arg: RunNowResult) => { - resolve(arg); - }) - .catch((err: Error) => { - reject(err); + try { + // The actual promise returned from this function is resolved after the awaitTaskRunResult promise resolves. + // However, we do not wait to await this promise, as we want later execution to happen in parallel. + // The awaitTaskRunResult promise is resolved once the ephemeral task is successfully executed (technically, when a TaskEventType.TASK_RUN is emitted with the same id). + // However, the ephemeral task won't even get into the queue until the subsequent this.ephemeralTaskLifecycle.attemptToRun is called (which puts it in the queue). + // The reason for all this confusion? Timing. + // In the this.ephemeralTaskLifecycle.attemptToRun, it's possible that the ephemeral task is put into the queue and processed before this function call returns anything. + // If that happens, putting the awaitTaskRunResult after would just hang because the task already completed. We need to listen for the completion before we add it to the queue to avoid this possibility. + const { cancel, resolveOnCancel } = cancellablePromise(); + this.awaitTaskRunResult(id, resolveOnCancel) + .then((arg: RunNowResult) => { + resolve(arg); + }) + .catch((err: Error) => { + reject(err); + }); + const attemptToRunResult = this.ephemeralTaskLifecycle.attemptToRun({ + id, + scheduledAt: new Date(), + runAt: new Date(), + status: TaskStatus.Idle, + ownerId: this.taskManagerId, + ...modifiedTask, }); - const attemptToRunResult = this.ephemeralTaskLifecycle.attemptToRun({ - id, - scheduledAt: new Date(), - runAt: new Date(), - status: TaskStatus.Idle, - ownerId: this.taskManagerId, - ...modifiedTask, - }); - if (isErr(attemptToRunResult)) { - cancel(); - reject( - new EphemeralTaskRejectedDueToCapacityError( - `Ephemeral Task of type ${task.taskType} was rejected`, - task - ) - ); + + if (isErr(attemptToRunResult)) { + cancel(); + reject( + new EphemeralTaskRejectedDueToCapacityError( + `Ephemeral Task of type ${task.taskType} was rejected`, + task + ) + ); + } + } catch (error) { + reject(error); } }); } diff --git a/x-pack/plugins/timelines/common/constants.ts b/x-pack/plugins/timelines/common/constants.ts index 0c03682cc8332..262ab841492e3 100644 --- a/x-pack/plugins/timelines/common/constants.ts +++ b/x-pack/plugins/timelines/common/constants.ts @@ -21,3 +21,4 @@ export const FILTER_IN_PROGRESS: AlertStatus = 'in-progress'; export const FILTER_ACKNOWLEDGED: AlertStatus = 'acknowledged'; export const RAC_ALERTS_BULK_UPDATE_URL = '/internal/rac/alerts/bulk_update'; +export const DETECTION_ENGINE_SIGNALS_STATUS_URL = '/api/detection_engine/signals/status'; diff --git a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts index 281a1fcc91799..e85f2eaa12d72 100644 --- a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts @@ -61,6 +61,7 @@ export interface StatusBulkActionsProps { setEventsDeleted: SetEventsDeleted; onUpdateSuccess?: OnUpdateAlertStatusSuccess; onUpdateFailure?: OnUpdateAlertStatusError; + timelineId?: string; } export interface HeaderActionProps { width: number; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts b/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts index 542be06578d6b..47cd1ed92d661 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts @@ -40,15 +40,17 @@ export const useDataGridHeightHack = (pageSize: number, rowCount: number) => { gridVirtualized && gridVirtualized.children[0].clientHeight !== gridVirtualized.clientHeight // check if it has vertical scroll ) { - setHeight( - height + + setHeight((currHeight) => { + return ( + currHeight + gridVirtualized.children[0].clientHeight - gridVirtualized.clientHeight + MAGIC_GAP - ); + ); + }); } }, TIME_INTERVAL); - }, [pageSize, rowCount, height]); + }, [pageSize, rowCount]); return height; }; diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx index 779fddcad2562..e98d9fff04a0c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx @@ -41,7 +41,7 @@ import { useDeepEqualSelector } from '../../../hooks/use_selector'; import { defaultHeaders } from '../body/column_headers/default_headers'; import { buildCombinedQuery, getCombinedFilterQuery, resolverIsShowing } from '../helpers'; import { tGridActions, tGridSelectors } from '../../../store/t_grid'; -import { useTimelineEvents } from '../../../container'; +import { useTimelineEvents, InspectResponse, Refetch } from '../../../container'; import { StatefulBody } from '../body'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexGroup, UpdatedFlexItem } from '../styles'; import { Sort } from '../body/sort'; @@ -114,6 +114,7 @@ export interface TGridIntegratedProps { query: Query; renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; + setQuery: (inspect: InspectResponse, loading: boolean, refetch: Refetch) => void; sort: Sort[]; start: string; tGridEventRenderedViewEnabled: boolean; @@ -150,6 +151,7 @@ const TGridIntegratedComponent: React.FC = ({ query, renderCellValue, rowRenderers, + setQuery, sort, start, tGridEventRenderedViewEnabled, @@ -269,6 +271,10 @@ const TGridIntegratedComponent: React.FC = ({ } }, [loading]); + useEffect(() => { + setQuery(inspect, loading, refetch); + }, [inspect, loading, refetch, setQuery]); + return ( = ({ {!resolverIsShowing(graphEventId) && additionalFilters} - {tGridEventRenderedViewEnabled && entityType === 'alerts' && ( - - - - )} + {tGridEventRenderedViewEnabled && + ['detections-page', 'detections-rules-details-page'].includes(id) && ( + + + + )} {!graphEventId && graphOverlay == null && ( diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_status_bulk_actions.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_status_bulk_actions.tsx index e4ccf1b72529f..be4a75e443494 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_status_bulk_actions.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_status_bulk_actions.tsx @@ -120,6 +120,7 @@ export const AlertStatusBulkActionsComponent = React.memo void; +export type Refetch = () => void; export interface TimelineArgs { consumers: Record; diff --git a/x-pack/plugins/timelines/public/container/use_update_alerts.ts b/x-pack/plugins/timelines/public/container/use_update_alerts.ts index 7cce40b59632d..7f42ddc6e8211 100644 --- a/x-pack/plugins/timelines/public/container/use_update_alerts.ts +++ b/x-pack/plugins/timelines/public/container/use_update_alerts.ts @@ -10,7 +10,10 @@ import { CoreStart } from '../../../../../src/core/public'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import { AlertStatus } from '../../../timelines/common'; -import { RAC_ALERTS_BULK_UPDATE_URL } from '../../common/constants'; +import { + DETECTION_ENGINE_SIGNALS_STATUS_URL, + RAC_ALERTS_BULK_UPDATE_URL, +} from '../../common/constants'; /** * Update alert status by query @@ -18,25 +21,35 @@ import { RAC_ALERTS_BULK_UPDATE_URL } from '../../common/constants'; * @param status to update to('open' / 'closed' / 'acknowledged') * @param index index to be updated * @param query optional query object to update alerts by query. - * @param ids optional array of alert ids to update. Ignored if query passed. + * * @throws An error if response is not OK */ -export const useUpdateAlertsStatus = (): { +export const useUpdateAlertsStatus = ( + timelineId: string +): { updateAlertStatus: (params: { status: AlertStatus; index: string; - ids?: string[]; - query?: object; + query: object; }) => Promise; } => { const { http } = useKibana().services; return { - updateAlertStatus: async ({ status, index, ids, query }) => { - const { body } = await http.post(RAC_ALERTS_BULK_UPDATE_URL, { - body: JSON.stringify({ index, status, ...(query ? { query } : { ids }) }), - }); - return body; + updateAlertStatus: async ({ status, index, query }) => { + if (['detections-page', 'detections-rules-details-page'].includes(timelineId)) { + return http!.fetch(DETECTION_ENGINE_SIGNALS_STATUS_URL, { + method: 'POST', + body: JSON.stringify({ status, query }), + }); + } else { + const { body } = await http.post(RAC_ALERTS_BULK_UPDATE_URL, { + body: JSON.stringify({ index, status, query }), + }); + return body; + } }, }; }; + +// diff --git a/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx b/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx index 8fd637767a387..c9269436646ea 100644 --- a/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx +++ b/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx @@ -26,8 +26,9 @@ export const useStatusBulkActionItems = ({ setEventsDeleted, onUpdateSuccess, onUpdateFailure, + timelineId, }: StatusBulkActionsProps) => { - const { updateAlertStatus } = useUpdateAlertsStatus(); + const { updateAlertStatus } = useUpdateAlertsStatus(timelineId ?? ''); const { addSuccess, addError, addWarning } = useAppToasts(); const onAlertStatusUpdateSuccess = useCallback( diff --git a/x-pack/plugins/timelines/public/mock/t_grid.tsx b/x-pack/plugins/timelines/public/mock/t_grid.tsx index 6e0b9747186d1..3ae1a1d53c207 100644 --- a/x-pack/plugins/timelines/public/mock/t_grid.tsx +++ b/x-pack/plugins/timelines/public/mock/t_grid.tsx @@ -114,6 +114,7 @@ export const tGridIntegratedProps: TGridIntegratedProps = { }, renderCellValue: () => null, rowRenderers: [], + setQuery: () => null, sort: [ { columnId: '@timestamp', diff --git a/x-pack/plugins/timelines/public/plugin.ts b/x-pack/plugins/timelines/public/plugin.ts index 74e1f2b32844a..4b383ce392147 100644 --- a/x-pack/plugins/timelines/public/plugin.ts +++ b/x-pack/plugins/timelines/public/plugin.ts @@ -48,6 +48,13 @@ export class TimelinesPlugin implements Plugin { return getHoverActions(this._store!); }, getTGrid: (props: TGridProps) => { + if (props.type === 'standalone' && this._store) { + const { getState } = this._store; + const state = getState(); + if (state && state.app) { + this._store = undefined; + } + } return getTGridLazy(props, { store: this._store, storage: this._storage, diff --git a/x-pack/plugins/timelines/server/search_strategy/index_fields/index.ts b/x-pack/plugins/timelines/server/search_strategy/index_fields/index.ts index e20d76bdaf625..907907e978123 100644 --- a/x-pack/plugins/timelines/server/search_strategy/index_fields/index.ts +++ b/x-pack/plugins/timelines/server/search_strategy/index_fields/index.ts @@ -65,7 +65,7 @@ export const requestIndexFieldSearch = async ( }); return get(searchResponse, 'body.hits.total.value', 0) > 0; } else { - if (index.startsWith('.alerts-security.alerts')) { + if (index.startsWith('.alerts-observability')) { return indexPatternsFetcherAsInternalUser.getFieldsForWildcard({ pattern: index, }); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx index 6249e77ce31dc..55576c3f3ee7d 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx @@ -42,8 +42,8 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => toastNotifications.addDanger( i18n.translate('xpack.transform.clone.noIndexPatternErrorPromptText', { defaultMessage: - 'Unable to clone the transform . No index pattern exists for {indexPattern}.', - values: { indexPattern: indexPatternTitle }, + 'Unable to clone the transform {transformId}. No index pattern exists for {indexPattern}.', + values: { indexPattern: indexPatternTitle, transformId: item.id }, }) ); } else { @@ -52,11 +52,11 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => ); } } catch (e) { - toastNotifications.addDanger( - i18n.translate('xpack.transform.clone.errorPromptText', { + toastNotifications.addError(e, { + title: i18n.translate('xpack.transform.clone.errorPromptText', { defaultMessage: 'An error occurred checking if source index pattern exists', - }) - ); + }), + }); } }, [ diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx index b84b309c478fd..03e45b8271952 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx @@ -5,25 +5,63 @@ * 2.0. */ -import React, { useContext, useMemo, useState } from 'react'; +import React, { useCallback, useContext, useMemo, useState } from 'react'; -import { TransformConfigUnion } from '../../../../../../common/types/transform'; +import { i18n } from '@kbn/i18n'; import { TransformListAction, TransformListRow } from '../../../../common'; import { AuthorizationContext } from '../../../../lib/authorization'; import { editActionNameText, EditActionName } from './edit_action_name'; +import { useSearchItems } from '../../../../hooks/use_search_items'; +import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; +import { TransformConfigUnion } from '../../../../../../common/types/transform'; export const useEditAction = (forceDisable: boolean, transformNodes: number) => { const { canCreateTransform } = useContext(AuthorizationContext).capabilities; const [config, setConfig] = useState(); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [indexPatternId, setIndexPatternId] = useState(); + const closeFlyout = () => setIsFlyoutVisible(false); - const showFlyout = (newConfig: TransformConfigUnion) => { - setConfig(newConfig); - setIsFlyoutVisible(true); - }; + + const { getIndexPatternIdByTitle } = useSearchItems(undefined); + const toastNotifications = useToastNotifications(); + const appDeps = useAppDependencies(); + const indexPatterns = appDeps.data.indexPatterns; + + const clickHandler = useCallback( + async (item: TransformListRow) => { + try { + const indexPatternTitle = Array.isArray(item.config.source.index) + ? item.config.source.index.join(',') + : item.config.source.index; + const currentIndexPatternId = getIndexPatternIdByTitle(indexPatternTitle); + + if (currentIndexPatternId === undefined) { + toastNotifications.addWarning( + i18n.translate('xpack.transform.edit.noIndexPatternErrorPromptText', { + defaultMessage: + 'Unable to get index pattern the transform {transformId}. No index pattern exists for {indexPattern}.', + values: { indexPattern: indexPatternTitle, transformId: item.id }, + }) + ); + } + setIndexPatternId(currentIndexPatternId); + setConfig(item.config); + setIsFlyoutVisible(true); + } catch (e) { + toastNotifications.addError(e, { + title: i18n.translate('xpack.transform.edit.errorPromptText', { + defaultMessage: 'An error occurred checking if source index pattern exists', + }), + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [indexPatterns, toastNotifications, getIndexPatternIdByTitle] + ); const action: TransformListAction = useMemo( () => ({ @@ -32,10 +70,10 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => description: editActionNameText, icon: 'pencil', type: 'icon', - onClick: (item: TransformListRow) => showFlyout(item.config), + onClick: (item: TransformListRow) => clickHandler(item), 'data-test-subj': 'transformActionEdit', }), - [canCreateTransform, forceDisable, transformNodes] + [canCreateTransform, clickHandler, forceDisable, transformNodes] ); return { @@ -43,6 +81,6 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => config, closeFlyout, isFlyoutVisible, - showFlyout, + indexPatternId, }; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx index faa304678c0fa..55225e0ff45c0 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx @@ -30,7 +30,6 @@ import { getErrorMessage } from '../../../../../../common/utils/errors'; import { refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../../../../common'; import { useToastNotifications } from '../../../../app_dependencies'; - import { useApi } from '../../../../hooks/use_api'; import { EditTransformFlyoutCallout } from './edit_transform_flyout_callout'; @@ -43,9 +42,14 @@ import { interface EditTransformFlyoutProps { closeFlyout: () => void; config: TransformConfigUnion; + indexPatternId?: string; } -export const EditTransformFlyout: FC = ({ closeFlyout, config }) => { +export const EditTransformFlyout: FC = ({ + closeFlyout, + config, + indexPatternId, +}) => { const api = useApi(); const toastNotifications = useToastNotifications(); @@ -96,7 +100,10 @@ export const EditTransformFlyout: FC = ({ closeFlyout, }> - + {errorMessage !== undefined && ( <> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx index d434e2e719f5e..40ccd68724400 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx @@ -5,23 +5,58 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, useEffect, useMemo, useState } from 'react'; -import { EuiForm, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import { EuiForm, EuiAccordion, EuiSpacer, EuiSelect, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { EditTransformFlyoutFormTextInput } from './edit_transform_flyout_form_text_input'; import { UseEditTransformFlyoutReturnType } from './use_edit_transform_flyout'; +import { useAppDependencies } from '../../../../app_dependencies'; +import { KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/common'; interface EditTransformFlyoutFormProps { editTransformFlyout: UseEditTransformFlyoutReturnType; + indexPatternId?: string; } export const EditTransformFlyoutForm: FC = ({ editTransformFlyout: [state, dispatch], + indexPatternId, }) => { const formFields = state.formFields; + const [dateFieldNames, setDateFieldNames] = useState([]); + + const appDeps = useAppDependencies(); + const indexPatternsClient = appDeps.data.indexPatterns; + + useEffect( + function getDateFields() { + let unmounted = false; + if (indexPatternId !== undefined) { + indexPatternsClient.get(indexPatternId).then((indexPattern) => { + if (indexPattern) { + const dateTimeFields = indexPattern.fields + .filter((f) => f.type === KBN_FIELD_TYPES.DATE) + .map((f) => f.name) + .sort(); + if (!unmounted) { + setDateFieldNames(dateTimeFields); + } + } + }); + return () => { + unmounted = true; + }; + } + }, + [indexPatternId, indexPatternsClient] + ); + + const retentionDateFieldOptions = useMemo(() => { + return Array.isArray(dateFieldNames) ? dateFieldNames.map((text: string) => ({ text })) : []; + }, [dateFieldNames]); return ( @@ -112,19 +147,57 @@ export const EditTransformFlyoutForm: FC = ({ paddingSize="s" >
      - {' '} - dispatch({ field: 'retentionPolicyField', value })} - value={formFields.retentionPolicyField.value} - /> + { + // If index pattern or date fields info not available + // gracefully defaults to text input + indexPatternId ? ( + 0} + error={formFields.retentionPolicyField.errorMessages} + helpText={i18n.translate( + 'xpack.transform.transformList.editFlyoutFormRetentionPolicyDateFieldHelpText', + { + defaultMessage: + 'Select the date field that can be used to identify out of date documents in the destination index.', + } + )} + > + + dispatch({ field: 'retentionPolicyField', value: e.target.value }) + } + /> + + ) : ( + dispatch({ field: 'retentionPolicyField', value })} + value={formFields.retentionPolicyField.value} + /> + ) + } {startAction.isModalVisible && } {editAction.config && editAction.isFlyoutVisible && ( - + )} {deleteAction.isModalVisible && } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1350b9d799a7c..082b24d3a9c05 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5580,9 +5580,6 @@ "xpack.apm.rum.visitorBreakdown.operatingSystem": "オペレーティングシステム", "xpack.apm.rum.visitorBreakdownMap.avgPageLoadDuration": "平均ページ読み込み時間", "xpack.apm.rum.visitorBreakdownMap.pageLoadDurationByRegion": "地域別ページ読み込み時間 (平均) ", - "xpack.apm.searchBar.inspectEsQueriesEnabled.callout.description": "ブラウザーの開発者ツールを開き、API応答を確認すると、すべてのElasticsearchクエリを検査できます。この設定はKibanaの{advancedSettingsLink}で無効にでkます", - "xpack.apm.searchBar.inspectEsQueriesEnabled.callout.description.advancedSettings": "高度な設定", - "xpack.apm.searchBar.inspectEsQueriesEnabled.callout.title": "調査可能なESクエリ (`apm:enableInspectEsQueries`) ", "xpack.apm.searchInput.filter": "フィルター...", "xpack.apm.selectPlaceholder": "オプションを選択:", "xpack.apm.serviceDetails.errorsTabLabel": "エラー", @@ -5660,7 +5657,6 @@ "xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningTitle": "JVM を特定できませんでした", "xpack.apm.serviceNodeNameMissing": " (空) ", "xpack.apm.serviceOveriew.errorsTableOccurrences": "{occurrencesCount} occ.", - "xpack.apm.serviceOverview.dependenciesTableColumnBackend": "バックエンド", "xpack.apm.serviceOverview.dependenciesTableTitle": "依存関係", "xpack.apm.serviceOverview.errorsTableColumnLastSeen": "前回の認識", "xpack.apm.serviceOverview.errorsTableColumnName": "名前", @@ -20260,7 +20256,6 @@ "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.cannotEditLists": "これらの権限がない場合は、値リストを作成したり編集したりできません。", "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.cannotEditRules": "その権限がない場合、検出エンジンルールを作製したり編集したりできません。", "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.essenceDescription": "この機能のすべてにアクセスするには、次の権限が必要です。サポートについては、管理者にお問い合わせください。", - "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.missingFeaturePrivileges": "{index}機能の{privileges}権限が不足しています。{explanation}", "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.missingIndexPrivileges": "{index}インデックスの{privileges}権限が不足しています。{explanation}", "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageTitle": "権限が不十分です", "xpack.securitySolution.detectionEngine.mitreAttack.addSubtechniqueTitle": "サブ手法を追加", @@ -23569,7 +23564,6 @@ "xpack.transform.clone.errorPromptText": "ソースインデックスパターンが存在するかどうかを確認するときにエラーが発生しました", "xpack.transform.clone.errorPromptTitle": "変換構成の取得中にエラーが発生しました。", "xpack.transform.clone.fetchErrorPromptText": "KibanaインデックスパターンIDを取得できませんでした。", - "xpack.transform.clone.noIndexPatternErrorPromptText": "変換を複製できません。{indexPattern}のインデックスパターンが存在しません。", "xpack.transform.cloneTransform.breadcrumbTitle": "クローン変換", "xpack.transform.createTransform.breadcrumbTitle": "変換の作成", "xpack.transform.deleteTransform.deleteAnalyticsWithIndexErrorMessage": "ディスティネーションインデックス{destinationIndex}の削除中にエラーが発生しました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 85889a4094036..83d99af185070 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5608,9 +5608,6 @@ "xpack.apm.rum.visitorBreakdown.operatingSystem": "操作系统", "xpack.apm.rum.visitorBreakdownMap.avgPageLoadDuration": "页面加载平均持续时间", "xpack.apm.rum.visitorBreakdownMap.pageLoadDurationByRegion": "按区域列出的页面加载持续时间(平均值)", - "xpack.apm.searchBar.inspectEsQueriesEnabled.callout.description": "现在可以通过打开浏览器的开发工具和查看 API 响应,来检查各个 Elasticsearch 查询。该设置可以在 Kibana 的“{advancedSettingsLink}”中禁用", - "xpack.apm.searchBar.inspectEsQueriesEnabled.callout.description.advancedSettings": "高级设置", - "xpack.apm.searchBar.inspectEsQueriesEnabled.callout.title": "可检查的 ES 查询 (`apm:enableInspectEsQueries`)", "xpack.apm.searchInput.filter": "筛选...", "xpack.apm.selectPlaceholder": "选择选项:", "xpack.apm.serviceDetails.errorsTabLabel": "错误", @@ -5688,7 +5685,6 @@ "xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningTitle": "找不到 JVM", "xpack.apm.serviceNodeNameMissing": "(空)", "xpack.apm.serviceOveriew.errorsTableOccurrences": "{occurrencesCount} 次", - "xpack.apm.serviceOverview.dependenciesTableColumnBackend": "后端", "xpack.apm.serviceOverview.dependenciesTableTitle": "依赖项", "xpack.apm.serviceOverview.errorsTableColumnLastSeen": "最后看到时间", "xpack.apm.serviceOverview.errorsTableColumnName": "名称", @@ -20726,7 +20722,6 @@ "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.cannotEditLists": "没有这些权限,将无法创建或编辑值列表。", "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.cannotEditRules": "没有该权限,将无法创建或编辑检测引擎规则。", "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.essenceDescription": "您需要以下权限,才能完全使用此功能。有关进一步帮助,请联系您的管理员。", - "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.missingFeaturePrivileges": "缺失 {privileges} 权限,无法使用 {index} 功能。{explanation}", "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.missingIndexPrivileges": "缺失 {privileges} 权限,无法使用 {index} 索引。{explanation}", "xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageTitle": "权限不足", "xpack.securitySolution.detectionEngine.mitreAttack.addSubtechniqueTitle": "添加子技术", @@ -24120,7 +24115,6 @@ "xpack.transform.clone.errorPromptText": "检查源索引模式是否存在时发生错误", "xpack.transform.clone.errorPromptTitle": "获取转换配置时发生错误。", "xpack.transform.clone.fetchErrorPromptText": "无法提取 Kibana 索引模式 ID。", - "xpack.transform.clone.noIndexPatternErrorPromptText": "无法克隆转换。对于 {indexPattern},不存在索引模式。", "xpack.transform.cloneTransform.breadcrumbTitle": "克隆转换", "xpack.transform.createTransform.breadcrumbTitle": "创建转换", "xpack.transform.deleteTransform.deleteAnalyticsWithIndexErrorMessage": "删除目标索引 {destinationIndex} 时发生错误", diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rbac_legacy.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rbac_legacy.ts index 7bc3353898598..e84eaf2cea04d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rbac_legacy.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rbac_legacy.ts @@ -10,6 +10,7 @@ import { UserAtSpaceScenarios, Superuser } from '../../scenarios'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { ESTestIndexTool, getUrlPrefix, ObjectRemover, AlertUtils } from '../../../common/lib'; import { setupSpacesAndUsers } from '..'; +import { SavedObjectsUtils } from '../../../../../../src/core/server/saved_objects'; // eslint-disable-next-line import/no-default-export export default function alertTests({ getService }: FtrProviderContext) { @@ -20,13 +21,38 @@ export default function alertTests({ getService }: FtrProviderContext) { const supertestWithoutAuth = getService('supertestWithoutAuth'); const esTestIndexTool = new ESTestIndexTool(es, retry); - const MIGRATED_ACTION_ID = '17f38826-5a8d-4a76-975a-b496e7fffe0b'; + const MIGRATED_ACTION_ID = SavedObjectsUtils.getConvertedObjectId( + 'space1', + 'action', + '17f38826-5a8d-4a76-975a-b496e7fffe0b' + ); + const MIGRATED_ALERT_ID: Record = { - space_1_all_alerts_none_actions: '6ee9630a-a20e-44af-9465-217a3717d2ab', - space_1_all_with_restricted_fixture: '5cc59319-74ee-4edc-8646-a79ea91067cd', - space_1_all: 'd41a6abb-b93b-46df-a80a-926221ea847c', - global_read: '362e362b-a137-4aa2-9434-43e3d0d84a34', - superuser: 'b384be60-ec53-4b26-857e-0253ee55b277', + space_1_all_alerts_none_actions: SavedObjectsUtils.getConvertedObjectId( + 'space1', + 'alert', + '6ee9630a-a20e-44af-9465-217a3717d2ab' + ), + space_1_all_with_restricted_fixture: SavedObjectsUtils.getConvertedObjectId( + 'space1', + 'alert', + '5cc59319-74ee-4edc-8646-a79ea91067cd' + ), + space_1_all: SavedObjectsUtils.getConvertedObjectId( + 'space1', + 'alert', + 'd41a6abb-b93b-46df-a80a-926221ea847c' + ), + global_read: SavedObjectsUtils.getConvertedObjectId( + 'space1', + 'alert', + '362e362b-a137-4aa2-9434-43e3d0d84a34' + ), + superuser: SavedObjectsUtils.getConvertedObjectId( + 'space1', + 'alert', + 'b384be60-ec53-4b26-857e-0253ee55b277' + ), }; describe('alerts', () => { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts index 99a12dc3437de..f45ad28e2cdc5 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts @@ -193,7 +193,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { const esResponse = await es.get>({ index: '.kibana', - id: `${Spaces.space1.id}:alert:${response.body.id}`, + id: `alert:${response.body.id}`, }); expect(esResponse.statusCode).to.eql(200); const rawActions = (esResponse.body._source as any)?.alert.actions ?? []; diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 40a451ffb5cfe..bbb0fc60cb3ce 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -32,16 +32,7 @@ export default function ({ getService }: FtrProviderContext) { actions: ['all', 'read'], stackAlerts: ['all', 'read'], ml: ['all', 'read'], - siem: [ - 'all', - 'read', - 'minimal_all', - 'minimal_read', - 'cases_all', - 'cases_read', - 'alerts_all', - 'alerts_read', - ], + siem: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_all', 'cases_read'], observabilityCases: ['all', 'read'], uptime: ['all', 'read'], infrastructure: ['all', 'read'], diff --git a/x-pack/test/apm_api_integration/tests/inspect/inspect.ts b/x-pack/test/apm_api_integration/tests/inspect/inspect.ts index 77ceedaeb68b9..c2a4dfb77d0e6 100644 --- a/x-pack/test/apm_api_integration/tests/inspect/inspect.ts +++ b/x-pack/test/apm_api_integration/tests/inspect/inspect.ts @@ -53,11 +53,13 @@ export default function customLinksTests({ getService }: FtrProviderContext) { // @ts-expect-error expect(Object.keys(body._inspect[0])).to.eql([ - 'operationName', + 'id', + 'json', + 'name', 'response', - 'duration', - 'requestType', - 'requestParams', + 'startTime', + 'stats', + 'status', ]); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_index.ts index 8eacd4231a92e..4748e39cd3a46 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_index.ts @@ -82,15 +82,15 @@ export default ({ getService }: FtrProviderContext) => { expect(body).to.eql({ message: 'index for this space does not exist', status_code: 404 }); }); - it('should be able to create a signal index when it has not been created yet', async () => { + it('should NOT be able to create a signal index when it has not been created yet. Should return a 403 and error that the user is unauthorized', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_INDEX_URL) .set('kbn-xsrf', 'true') .auth(role, 'changeme') .send() - .expect(200); - - expect(body).to.eql({ acknowledged: true }); + .expect(403); + expect(body.message).to.match(/^security_exception/); + expect(body.status_code).to.eql(403); }); it('should be able to read the index name and status as not being outdated', async () => { @@ -103,7 +103,7 @@ export default ({ getService }: FtrProviderContext) => { .send() .expect(200); expect(body).to.eql({ - index_mapping_outdated: false, + index_mapping_outdated: null, name: `${DEFAULT_SIGNALS_INDEX}-default`, }); }); @@ -129,15 +129,15 @@ export default ({ getService }: FtrProviderContext) => { expect(body).to.eql({ message: 'index for this space does not exist', status_code: 404 }); }); - it('should be able to create a signal index when it has not been created yet.', async () => { + it('should NOT be able to create a signal index when it has not been created yet. Should return a 403 and error that the user is unauthorized', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_INDEX_URL) .set('kbn-xsrf', 'true') .auth(role, 'changeme') .send() - .expect(200); - - expect(body).to.eql({ acknowledged: true }); + .expect(403); + expect(body.message).to.match(/^security_exception/); + expect(body.status_code).to.eql(403); }); it('should be able to read the index name and status as not being outdated', async () => { @@ -150,7 +150,7 @@ export default ({ getService }: FtrProviderContext) => { .send() .expect(200); expect(body).to.eql({ - index_mapping_outdated: false, + index_mapping_outdated: null, name: `${DEFAULT_SIGNALS_INDEX}-default`, }); }); @@ -226,15 +226,15 @@ export default ({ getService }: FtrProviderContext) => { expect(body).to.eql({ message: 'index for this space does not exist', status_code: 404 }); }); - it('should be able to create a signal index when it has not been created yet', async () => { + it('should NOT be able to create a signal index when it has not been created yet. Should return a 403 and error that the user is unauthorized', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_INDEX_URL) .set('kbn-xsrf', 'true') .auth(role, 'changeme') .send() - .expect(200); - - expect(body).to.eql({ acknowledged: true }); + .expect(403); + expect(body.message).to.match(/^security_exception/); + expect(body.status_code).to.eql(403); }); it('should be able to read the index name and status as not being outdated', async () => { @@ -272,16 +272,16 @@ export default ({ getService }: FtrProviderContext) => { .expect(404); expect(body).to.eql({ message: 'index for this space does not exist', status_code: 404 }); }); - // here - it('should be able to create a signal index when it has not been created yet', async () => { + + it('should NOT be able to create a signal index when it has not been created yet. Should return a 403 and error that the user is unauthorized', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_INDEX_URL) .set('kbn-xsrf', 'true') .auth(role, 'changeme') .send() - .expect(200); - - expect(body).to.eql({ acknowledged: true }); + .expect(403); + expect(body.message).to.match(/^security_exception/); + expect(body.status_code).to.eql(403); }); it('should be able to read the index name and status as not being outdated', async () => { @@ -294,7 +294,7 @@ export default ({ getService }: FtrProviderContext) => { .send() .expect(200); expect(body).to.eql({ - index_mapping_outdated: false, + index_mapping_outdated: null, name: `${DEFAULT_SIGNALS_INDEX}-default`, }); }); @@ -370,14 +370,15 @@ export default ({ getService }: FtrProviderContext) => { expect(body).to.eql({ message: 'index for this space does not exist', status_code: 404 }); }); - it('should be able to create a signal index when it has not been created yet', async () => { + it('should NOT be able to create a signal index when it has not been created yet. Should return a 401 unauthorized', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_INDEX_URL) .set('kbn-xsrf', 'true') .auth(role, 'changeme') .send() - .expect(200); - expect(body).to.eql({ acknowledged: true }); + .expect(403); + expect(body.message).to.match(/^security_exception/); + expect(body.status_code).to.eql(403); }); it('should be able to read the index name and status as being outdated.', async () => { @@ -416,14 +417,15 @@ export default ({ getService }: FtrProviderContext) => { expect(body).to.eql({ message: 'index for this space does not exist', status_code: 404 }); }); - it('should be able to create a signal index when it has not been created yet', async () => { + it('should NOT be able to create a signal index when it has not been created yet. Should return a 401 unauthorized', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_INDEX_URL) .set('kbn-xsrf', 'true') .auth(role, 'changeme') .send() - .expect(200); - expect(body).to.eql({ acknowledged: true }); + .expect(403); + expect(body.message).to.match(/^security_exception/); + expect(body.status_code).to.eql(403); }); it('should be able to read the index name and status as being outdated.', async () => { diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/data_stream/test_stream/agent/stream/stream.yml.hbs b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/data_stream/test_stream/agent/stream/stream.yml.hbs new file mode 100644 index 0000000000000..2870385f21f95 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/data_stream/test_stream/agent/stream/stream.yml.hbs @@ -0,0 +1 @@ +config.version: "2" diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/data_stream/test_stream/fields/fields.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/data_stream/test_stream/fields/fields.yml new file mode 100644 index 0000000000000..6e003ed0ad147 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/data_stream/test_stream/fields/fields.yml @@ -0,0 +1,16 @@ +- name: data_stream.type + type: constant_keyword + description: > + Data stream type. +- name: data_stream.dataset + type: constant_keyword + description: > + Data stream dataset. +- name: data_stream.namespace + type: constant_keyword + description: > + Data stream namespace. +- name: '@timestamp' + type: date + description: > + Event timestamp. diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/data_stream/test_stream/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/data_stream/test_stream/manifest.yml new file mode 100644 index 0000000000000..461d4fa941708 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/data_stream/test_stream/manifest.yml @@ -0,0 +1,4 @@ +title: Test stream +type: logs +streams: + - input: test_input diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/docs/README.md b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/docs/README.md new file mode 100644 index 0000000000000..0b9b18421c9dc --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/docs/README.md @@ -0,0 +1,3 @@ +# Test package + +This is a test package for testing automated upgrades for package policies diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/manifest.yml new file mode 100644 index 0000000000000..346ea4d2bcfad --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.7.0-add-stream-with-no-vars/manifest.yml @@ -0,0 +1,23 @@ +format_version: 1.0.0 +name: package_policy_upgrade +title: Tests package policy upgrades +description: This is a test package for upgrading package policies +version: 0.7.0-add-stream-with-no-vars +categories: [] +release: beta +type: integration +license: basic +requirement: + elasticsearch: + versions: '>7.7.0' + kibana: + versions: '>7.7.0' +policy_templates: + - name: package_policy_upgrade_new + title: Package Policy Upgrade New + description: Test Package for Upgrading Package Policies + inputs: + - type: test_input + title: Test Input + description: Test Input + enabled: true diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/data_stream/test_stream/agent/stream/stream.yml.hbs b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/data_stream/test_stream/agent/stream/stream.yml.hbs new file mode 100644 index 0000000000000..2870385f21f95 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/data_stream/test_stream/agent/stream/stream.yml.hbs @@ -0,0 +1 @@ +config.version: "2" diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/data_stream/test_stream/fields/fields.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/data_stream/test_stream/fields/fields.yml new file mode 100644 index 0000000000000..6e003ed0ad147 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/data_stream/test_stream/fields/fields.yml @@ -0,0 +1,16 @@ +- name: data_stream.type + type: constant_keyword + description: > + Data stream type. +- name: data_stream.dataset + type: constant_keyword + description: > + Data stream dataset. +- name: data_stream.namespace + type: constant_keyword + description: > + Data stream namespace. +- name: '@timestamp' + type: date + description: > + Event timestamp. diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/data_stream/test_stream/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/data_stream/test_stream/manifest.yml new file mode 100644 index 0000000000000..8b8ea1987ccc3 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/data_stream/test_stream/manifest.yml @@ -0,0 +1,17 @@ +title: Test stream +type: logs +streams: + - input: test_input + vars: + - name: test_var_new + type: text + title: Test Var New + default: Test Var New + required: true + show_user: true + - name: test_var_new_2 + type: text + title: Test Var New 2 + default: Test Var New 2 + required: true + show_user: true diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/docs/README.md b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/docs/README.md new file mode 100644 index 0000000000000..0b9b18421c9dc --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/docs/README.md @@ -0,0 +1,3 @@ +# Test package + +This is a test package for testing automated upgrades for package policies diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/manifest.yml new file mode 100644 index 0000000000000..bd61453fdaac8 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.8.0-add-vars-to-stream-with-no-vars/manifest.yml @@ -0,0 +1,23 @@ +format_version: 1.0.0 +name: package_policy_upgrade +title: Tests package policy upgrades +description: This is a test package for upgrading package policies +version: 0.8.0-add-vars-to-stream-with-no-vars +categories: [] +release: beta +type: integration +license: basic +requirement: + elasticsearch: + versions: '>7.7.0' + kibana: + versions: '>7.7.0' +policy_templates: + - name: package_policy_upgrade_new + title: Package Policy Upgrade New + description: Test Package for Upgrading Package Policies + inputs: + - type: test_input + title: Test Input + description: Test Input + enabled: true diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts b/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts index e75bcfaf75142..3a7d6f5d6b19e 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts @@ -803,6 +803,126 @@ export default function (providerContext: FtrProviderContext) { }); }); + describe('when upgrading to a version where an input with no variables has variables added', function () { + withTestPackageVersion('0.8.0-add-vars-to-stream-with-no-vars'); + + beforeEach(async function () { + const { body: agentPolicyResponse } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test policy', + namespace: 'default', + }) + .expect(200); + + agentPolicyId = agentPolicyResponse.item.id; + + const { body: packagePolicyResponse } = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'package_policy_upgrade_1', + description: '', + namespace: 'default', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [ + { + policy_template: 'package_policy_upgrade', + type: 'test_input', + enabled: true, + streams: [ + { + id: 'test-package_policy_upgrade-xxxx', + enabled: true, + data_stream: { + type: 'test_stream', + dataset: 'package_policy_upgrade.test_stream', + }, + }, + ], + }, + ], + package: { + name: 'package_policy_upgrade', + title: 'This is a test package for upgrading package policies', + version: '0.7.0-add-stream-with-no-vars', + }, + }); + + packagePolicyId = packagePolicyResponse.item.id; + }); + + afterEach(async function () { + await supertest + .post(`/api/fleet/package_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ packagePolicyIds: [packagePolicyId] }) + .expect(200); + + await supertest + .post('/api/fleet/agent_policies/delete') + .set('kbn-xsrf', 'xxxx') + .send({ agentPolicyId }) + .expect(200); + }); + + describe('dry run', function () { + it('returns a valid diff', async function () { + const { body }: { body: UpgradePackagePolicyDryRunResponse } = await supertest + .post(`/api/fleet/package_policies/upgrade`) + .set('kbn-xsrf', 'xxxx') + .send({ + packagePolicyIds: [packagePolicyId], + dryRun: true, + }) + .expect(200); + + expect(body[0].hasErrors).to.be(false); + + const oldInput = body[0].diff?.[0].inputs.find((input) => input.type === 'test_input'); + const oldStream = oldInput?.streams.find( + (stream) => stream.data_stream.dataset === 'package_policy_upgrade.test_stream' + ); + + expect(oldStream?.vars).to.be(undefined); + + const newInput = body[0].diff?.[1].inputs.find((input) => input.type === 'test_input'); + const newStream = newInput?.streams.find( + (stream) => stream.data_stream.dataset === 'package_policy_upgrade.test_stream' + ); + + expect(newStream?.vars).to.eql({ + test_var_new: { + value: 'Test Var New', + type: 'text', + }, + test_var_new_2: { + value: 'Test Var New 2', + type: 'text', + }, + }); + }); + }); + + describe('upgrade', function () { + it('successfully upgrades package policy', async function () { + const { body }: { body: UpgradePackagePolicyResponse } = await supertest + .post(`/api/fleet/package_policies/upgrade`) + .set('kbn-xsrf', 'xxxx') + .send({ + packagePolicyIds: [packagePolicyId], + dryRun: false, + }) + .expect(200); + + expect(body[0].success).to.be(true); + }); + }); + }); + describe('when package policy is not found', function () { it('should return an 200 with errors when "dryRun:true" is provided', async function () { const { body }: { body: UpgradePackagePolicyDryRunResponse } = await supertest diff --git a/x-pack/test/functional/apps/transform/permissions/full_transform_access.ts b/x-pack/test/functional/apps/transform/permissions/full_transform_access.ts index d50943fad991a..5f74b2da213b0 100644 --- a/x-pack/test/functional/apps/transform/permissions/full_transform_access.ts +++ b/x-pack/test/functional/apps/transform/permissions/full_transform_access.ts @@ -158,10 +158,7 @@ export default function ({ getService }: FtrProviderContext) { 'should have the retention policy inputs enabled' ); await transform.editFlyout.openTransformEditAccordionRetentionPolicySettings(); - await transform.editFlyout.assertTransformEditFlyoutInputEnabled( - 'RetentionPolicyField', - true - ); + await transform.editFlyout.assertTransformEditFlyoutRetentionPolicySelectEnabled(true); await transform.editFlyout.assertTransformEditFlyoutInputEnabled( 'RetentionPolicyMaxAge', true diff --git a/x-pack/test/functional/es_archives/task_manager_tasks/data.json b/x-pack/test/functional/es_archives/task_manager_tasks/data.json new file mode 100644 index 0000000000000..b59abd341a7af --- /dev/null +++ b/x-pack/test/functional/es_archives/task_manager_tasks/data.json @@ -0,0 +1,61 @@ +{ + "type": "doc", + "value": { + "id": "task:be7e1250-3322-11eb-94c1-db6995e84f6a", + "index": ".kibana_task_manager_1", + "source": { + "migrationVersion": { + "task": "7.16.0" + }, + "references": [ + ], + "task": { + "attempts": 0, + "params": "{\"spaceId\":\"user1\",\"alertId\":\"0359d7fcc04da9878ee9aadbda38ba55\"}", + "retryAt": "2020-11-30T15:43:39.626Z", + "runAt": "2020-11-30T15:43:08.277Z", + "scheduledAt": "2020-11-30T15:43:08.277Z", + "scope": [ + "testing" + ], + "startedAt": null, + "state": "{}", + "status": "idle", + "taskType": "alerting:0359d7fcc04da9878ee9aadbda38ba55" + }, + "type": "task", + "updated_at": "2020-11-30T15:43:08.277Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "task:be7e1250-3322-11eb-94c1-db6995e8389f", + "index": ".kibana_task_manager_1", + "source": { + "migrationVersion": { + "task": "7.16.0" + }, + "references": [ + ], + "task": { + "attempts": 0, + "params": "{\"spaceId\":\"user1\",\"actionTaskParamsId\":\"6e96ac5e648f57523879661ea72525b7\"}", + "retryAt": "2020-11-30T15:43:39.626Z", + "runAt": "2020-11-30T15:43:08.277Z", + "scheduledAt": "2020-11-30T15:43:08.277Z", + "scope": [ + "testing" + ], + "startedAt": null, + "state": "{}", + "status": "idle", + "taskType": "actions:6e96ac5e648f57523879661ea72525b7" + }, + "type": "task", + "updated_at": "2020-11-30T15:43:08.277Z" + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/task_manager_tasks/mappings.json b/x-pack/test/functional/es_archives/task_manager_tasks/mappings.json new file mode 100644 index 0000000000000..6ec81326d1ca4 --- /dev/null +++ b/x-pack/test/functional/es_archives/task_manager_tasks/mappings.json @@ -0,0 +1,225 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "action": "6e96ac5e648f57523879661ea72525b7", + "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", + "alert": "0359d7fcc04da9878ee9aadbda38ba55", + "api_key_pending_invalidation": "16f515278a295f6245149ad7c5ddedb7", + "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", + "apm-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "app_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "application_usage_daily": "43b8830d5d0df85a6823d290885fc9fd", + "application_usage_totals": "3d1b76c39bfb2cc8296b024d73854724", + "application_usage_transactional": "3d1b76c39bfb2cc8296b024d73854724", + "search-session": "721df406dbb7e35ac22e4df6c3ad2b2a", + "canvas-element": "7390014e1091044523666d97247392fc", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "canvas-workpad-template": "ae2673f678281e2c055d764b153e9715", + "cases": "477f214ff61acc3af26a7b7818e380c1", + "cases-comments": "8a50736330e953bca91747723a319593", + "cases-configure": "387c5f3a3bda7e0ae0dd4e106f914a69", + "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", + "config": "c63748b75f39d0c54de12d12c1ccbc20", + "dashboard": "40554caf09725935e2c02e02563a2d07", + "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", + "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "epm-packages": "2b83397e3eaaaa8ef15e38813f3721c3", + "event_log_test": "bef808d4a9c27f204ffbda3359233931", + "exception-list": "67f055ab8c10abd7b2ebfd969b836788", + "exception-list-agnostic": "67f055ab8c10abd7b2ebfd969b836788", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "fleet-agent-actions": "9511b565b1cc6441a42033db3d5de8e9", + "fleet-agent-events": "e20a508b6e805189356be381dbfac8db", + "fleet-agents": "cb661e8ede2b640c42c8e5ef99db0683", + "fleet-enrollment-api-keys": "a69ef7ae661dab31561d6c6f052ef2a7", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "index-pattern": "45915a1ad866812242df474eb0479052", + "infrastructure-ui-source": "3d1b76c39bfb2cc8296b024d73854724", + "ingest-agent-policies": "8b0733cce189659593659dad8db426f0", + "ingest-outputs": "8854f34453a47e26f86a29f8f3b80b4e", + "ingest-package-policies": "c91ca97b1ff700f0fc64dc6b13d65a85", + "ingest_manager_settings": "02a03095f0e05b7a538fa801b88a217f", + "inventory-view": "3d1b76c39bfb2cc8296b024d73854724", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "lens": "52346cfec69ff7b47d5f0c12361a2797", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", + "map": "4a05b35c3a3a58fbc72dd0202dc3487f", + "maps-telemetry": "5ef305b18111b77789afefbd36b66171", + "metrics-explorer-view": "3d1b76c39bfb2cc8296b024d73854724", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-job": "3bb64c31915acf93fc724af137a0891b", + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "monitoring-telemetry": "2669d5ec15e82391cf58df4294ee9c68", + "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "originId": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "43012c7ebc4cb57054e0a490e4b43023", + "search-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "siem-detection-engine-rule-actions": "6569b288c169539db10cb262bf79de18", + "siem-detection-engine-rule-status": "ae783f41c6937db6b7a2ef5c93a9e9b0", + "siem-ui-timeline": "d12c5474364d737d17252acf1dc4585c", + "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", + "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "tag": "83d55da58f6530f7055415717ec06474", + "telemetry": "36a616f7026dfa617d6655df850fe16d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "215107c281839ea9b3ad5f6419819763", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "uptime-dynamic-settings": "3d1b76c39bfb2cc8296b024d73854724", + "url": "c7f66a0df8b1b52f17c28c4adb111105", + "visualization": "f819cf6636b75c9e76ba733a0c6ef355", + "workplace_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724" + } + }, + "dynamic": "strict", + "properties": { + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + ".kibana_task_manager": { + } + }, + "index": ".kibana_task_manager_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "originId": "2f4316de49999235636386fe51dc06c1", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "task": "235412e52d09e7165fac8a67a43ad6b4", + "type": "2f4316de49999235636386fe51dc06c1", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0" + } + }, + "dynamic": "strict", + "properties": { + "migrationVersion": { + "dynamic": "true", + "properties": { + "task": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "task": { + "properties": { + "attempts": { + "type": "integer" + }, + "ownerId": { + "type": "keyword" + }, + "params": { + "type": "text" + }, + "retryAt": { + "type": "date" + }, + "runAt": { + "type": "date" + }, + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } + }, + "scheduledAt": { + "type": "date" + }, + "scope": { + "type": "keyword" + }, + "startedAt": { + "type": "date" + }, + "state": { + "type": "text" + }, + "status": { + "type": "keyword" + }, + "taskType": { + "type": "keyword" + }, + "user": { + "type": "keyword" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/services/transform/edit_flyout.ts b/x-pack/test/functional/services/transform/edit_flyout.ts index fcb87fc9bec5b..cc230e2c38fca 100644 --- a/x-pack/test/functional/services/transform/edit_flyout.ts +++ b/x-pack/test/functional/services/transform/edit_flyout.ts @@ -37,6 +37,21 @@ export function TransformEditFlyoutProvider({ getService }: FtrProviderContext) ); }, + async assertTransformEditFlyoutRetentionPolicySelectEnabled(expectedValue: boolean) { + await testSubjects.existOrFail(`transformEditFlyoutRetentionPolicyFieldSelect`, { + timeout: 1000, + }); + const isEnabled = await testSubjects.isEnabled( + `transformEditFlyoutRetentionPolicyFieldSelect` + ); + expect(isEnabled).to.eql( + expectedValue, + `Expected 'transformEditFlyoutRetentionPolicyFieldSelect' input to be '${ + expectedValue ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); + }, + async assertTransformEditFlyoutInputEnabled(input: string, expectedValue: boolean) { await testSubjects.existOrFail(`transformEditFlyout${input}Input`, { timeout: 1000 }); const isEnabled = await testSubjects.isEnabled(`transformEditFlyout${input}Input`); diff --git a/x-pack/test/functional_enterprise_search/services/app_search_client.ts b/x-pack/test/functional_enterprise_search/services/app_search_client.ts index 8e829b97e9dda..457523bccf8c3 100644 --- a/x-pack/test/functional_enterprise_search/services/app_search_client.ts +++ b/x-pack/test/functional_enterprise_search/services/app_search_client.ts @@ -105,14 +105,20 @@ const search = async (engineName: string): Promise => { // Since the App Search API does not issue document receipts, the only way to tell whether or not documents // are fully indexed is to poll the search endpoint. export const waitForIndexedDocs = (engineName: string) => { - return new Promise(async function (resolve) { - let isReady = false; - while (!isReady) { - const response = await search(engineName); - if (response.results && response.results.length > 0) { - isReady = true; - resolve(); + return new Promise(async function (resolve, reject) { + try { + let isReady = false; + + while (!isReady) { + const response = await search(engineName); + + if (response.results && response.results.length > 0) { + isReady = true; + resolve(); + } } + } catch (error) { + reject(error); } }); }; diff --git a/x-pack/test/lists_api_integration/utils.ts b/x-pack/test/lists_api_integration/utils.ts index 8975feb6fbe05..b3816ad7563b8 100644 --- a/x-pack/test/lists_api_integration/utils.ts +++ b/x-pack/test/lists_api_integration/utils.ts @@ -116,21 +116,29 @@ export const waitFor = async ( timeoutWait: number = 10 ) => { await new Promise(async (resolve, reject) => { - let found = false; - let numberOfTries = 0; - while (!found && numberOfTries < Math.floor(maxTimeout / timeoutWait)) { - const itPasses = await functionToTest(); - if (itPasses) { - found = true; + try { + let found = false; + let numberOfTries = 0; + + while (!found && numberOfTries < Math.floor(maxTimeout / timeoutWait)) { + const itPasses = await functionToTest(); + + if (itPasses) { + found = true; + } else { + numberOfTries++; + } + + await new Promise((resolveTimeout) => setTimeout(resolveTimeout, timeoutWait)); + } + + if (found) { + resolve(); } else { - numberOfTries++; + reject(new Error(`timed out waiting for function ${functionName} condition to be true`)); } - await new Promise((resolveTimeout) => setTimeout(resolveTimeout, timeoutWait)); - } - if (found) { - resolve(); - } else { - reject(new Error(`timed out waiting for function ${functionName} condition to be true`)); + } catch (error) { + reject(error); } }); }; diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/index.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/index.ts index bab34312c363f..b1de32fdcc93c 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/index.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/index.ts @@ -13,5 +13,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./health_route')); loadTestFile(require.resolve('./task_management')); loadTestFile(require.resolve('./task_management_removed_types')); + + loadTestFile(require.resolve('./migrations')); }); } diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/migrations.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/migrations.ts new file mode 100644 index 0000000000000..caf62a1d364c0 --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/migrations.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import type { ApiResponse, estypes } from '@elastic/elasticsearch'; +import { TaskInstanceWithDeprecatedFields } from '../../../../plugins/task_manager/server/task'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { SavedObjectsUtils } from '../../../../../src/core/server/saved_objects'; + +export default function createGetTests({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const ALERT_ID = '0359d7fcc04da9878ee9aadbda38ba55'; + const ACTION_TASK_PARAMS_ID = '6e96ac5e648f57523879661ea72525b7'; + + describe('migrations', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/task_manager_tasks'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/task_manager_tasks'); + }); + + it('8.0.0 migrates actions tasks with legacy id to saved object ids', async () => { + // NOTE: We hae to use elastic search directly against the ".kibana" index because alerts do not expose the references which we want to test exists + const response = await es.get<{ task: TaskInstanceWithDeprecatedFields }>({ + index: '.kibana_task_manager', + id: 'task:be7e1250-3322-11eb-94c1-db6995e84f6a', + }); + expect(response.statusCode).to.eql(200); + expect(response.body._source?.task.params).to.eql( + `{"spaceId":"user1","alertId":"${SavedObjectsUtils.getConvertedObjectId( + 'user1', + 'alert', + ALERT_ID + )}"}` + ); + }); + + it('8.0.0 migrates actions tasks from legacy id to saved object ids', async () => { + const searchResult: ApiResponse< + estypes.SearchResponse<{ task: TaskInstanceWithDeprecatedFields }> + > = await es.search({ + index: '.kibana_task_manager', + body: { + query: { + term: { + _id: 'task:be7e1250-3322-11eb-94c1-db6995e8389f', + }, + }, + }, + }); + expect(searchResult.statusCode).to.equal(200); + expect((searchResult.body.hits.total as estypes.SearchTotalHits).value).to.equal(1); + const hit = searchResult.body.hits.hits[0]; + expect(hit!._source!.task.params!).to.equal( + `{"spaceId":"user1","actionTaskParamsId":"${SavedObjectsUtils.getConvertedObjectId( + 'user1', + 'action_task_params', + ACTION_TASK_PARAMS_ID + )}"}` + ); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index ba046a081b6d8..06398fdcd9658 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -21,8 +21,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const policyTestResources = getService('policyTestResources'); - // FLAKY: https://github.com/elastic/kibana/issues/100296 - describe.skip('When on the Endpoint Policy Details Page', function () { + describe('When on the Endpoint Policy Details Page', function () { describe('with an invalid policy id', () => { it('should display an error', async () => { await pageObjects.policy.navigateToPolicyDetails('invalid-id'); diff --git a/yarn.lock b/yarn.lock index 4d1520537ef6d..80c66de7e3553 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6457,6 +6457,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.14.1.tgz#b3d2eb91dafd0fd8b3fce7c61512ac66bd0364aa" integrity sha512-SkhzHdI/AllAgQSxXM89XwS1Tkic7csPdndUuTKabEwRcEfR8uQ/iPA3Dgio1rqsV3jtqZhY0QQni8rLswJM2w== +"@typescript-eslint/types@4.28.3": + version "4.28.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.28.3.tgz#8fffd436a3bada422c2c1da56060a0566a9506c7" + integrity sha512-kQFaEsQBQVtA9VGVyciyTbIg7S3WoKHNuOp/UF5RG40900KtGqfoiETWD/v0lzRXc+euVE9NXmfer9dLkUJrkA== + "@typescript-eslint/types@4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.3.0.tgz#1f0b2d5e140543e2614f06d48fb3ae95193c6ddf" @@ -6490,6 +6495,19 @@ semver "^7.3.2" tsutils "^3.17.1" +"@typescript-eslint/typescript-estree@^4.14.1": + version "4.28.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.28.3.tgz#253d7088100b2a38aefe3c8dd7bd1f8232ec46fb" + integrity sha512-YAb1JED41kJsqCQt1NcnX5ZdTA93vKFCMP4lQYG6CFxd0VzDJcKttRlMrlG+1qiWAw8+zowmHU1H0OzjWJzR2w== + dependencies: + "@typescript-eslint/types" "4.28.3" + "@typescript-eslint/visitor-keys" "4.28.3" + debug "^4.3.1" + globby "^11.0.3" + is-glob "^4.0.1" + semver "^7.3.5" + tsutils "^3.21.0" + "@typescript-eslint/visitor-keys@4.14.1": version "4.14.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.14.1.tgz#e93c2ff27f47ee477a929b970ca89d60a117da91" @@ -6498,6 +6516,14 @@ "@typescript-eslint/types" "4.14.1" eslint-visitor-keys "^2.0.0" +"@typescript-eslint/visitor-keys@4.28.3": + version "4.28.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.28.3.tgz#26ac91e84b23529968361045829da80a4e5251c4" + integrity sha512-ri1OzcLnk1HH4gORmr1dllxDzzrN6goUIz/P4MHFV0YZJDCADPR3RvYNp0PW2SetKTThar6wlbFTL00hV2Q+fg== + dependencies: + "@typescript-eslint/types" "4.28.3" + eslint-visitor-keys "^2.0.0" + "@typescript-eslint/visitor-keys@4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.3.0.tgz#0e5ab0a09552903edeae205982e8521e17635ae0" @@ -13120,6 +13146,11 @@ eslint-scope@^5.0.0: esrecurse "^4.1.0" estraverse "^4.1.1" +eslint-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/eslint-traverse/-/eslint-traverse-1.0.0.tgz#108d360a171a6e6334e1af0cee905a93bd0dcc53" + integrity sha512-bSp37rQs93LF8rZ409EI369DGCI4tELbFVmFNxI6QbuveS7VRxYVyUhwDafKN/enMyUh88HQQ7ZoGUHtPuGdcw== + eslint-utils@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" @@ -27515,6 +27546,13 @@ tsutils@^3.17.1: dependencies: tslib "^1.8.1" +tsutils@^3.21.0: + version "3.21.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" + integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== + dependencies: + tslib "^1.8.1" + tty-browserify@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"