From 10185fecb86d5b3212e6d5eb7b26d388d20aaf85 Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Tue, 31 Aug 2021 08:49:34 -0600 Subject: [PATCH 01/18] [Maps] Ensure draw tools updates by index name, not index pattern title (#108394) --- .../maps/public/actions/map_actions.ts | 27 +++++++++-- .../es_search_source/es_search_source.tsx | 45 +++++++++++++++---- .../es_search_source/util/feature_edit.ts | 3 +- .../get_indexes_matching_pattern.ts | 31 ++++++++----- .../server/data_indexing/indexing_routes.ts | 13 +++--- .../apis/maps/get_indexes_matching_pattern.js | 7 +-- 6 files changed, 91 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 45f3299db9041..c1db14347460f 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -6,6 +6,7 @@ */ import _ from 'lodash'; +import { i18n } from '@kbn/i18n'; import { AnyAction, Dispatch } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; import turfBboxPolygon from '@turf/bbox-polygon'; @@ -59,6 +60,7 @@ import { cleanTooltipStateForLayer } from './tooltip_actions'; import { VectorLayer } from '../classes/layers/vector_layer'; import { SET_DRAW_MODE } from './ui_actions'; import { expandToTileBoundaries } from '../../common/geo_tile_utils'; +import { getToasts } from '../kibana_services'; export function setMapInitError(errorMessage: string) { return { @@ -367,8 +369,17 @@ export function addNewFeatureToIndex(geometry: Geometry | Position[]) { if (!layer || !(layer instanceof VectorLayer)) { return; } - await layer.addFeature(geometry); - await dispatch(syncDataForLayer(layer, true)); + + try { + await layer.addFeature(geometry); + await dispatch(syncDataForLayer(layer, true)); + } catch (e) { + getToasts().addError(e, { + title: i18n.translate('xpack.maps.mapActions.addFeatureError', { + defaultMessage: `Unable to add feature to index.`, + }), + }); + } }; } @@ -386,7 +397,15 @@ export function deleteFeatureFromIndex(featureId: string) { if (!layer || !(layer instanceof VectorLayer)) { return; } - await layer.deleteFeature(featureId); - await dispatch(syncDataForLayer(layer, true)); + try { + await layer.deleteFeature(featureId); + await dispatch(syncDataForLayer(layer, true)); + } catch (e) { + getToasts().addError(e, { + title: i18n.translate('xpack.maps.mapActions.removeFeatureError', { + defaultMessage: `Unable to remove feature from index.`, + }), + }); + } }; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index 93362a1721ce5..1ca7ddb586293 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -441,15 +441,23 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye return !!(scalingType === SCALING_TYPES.TOP_HITS && topHitsSplitField); } - async supportsFeatureEditing(): Promise { + async getSourceIndexList(): Promise { await this.getIndexPattern(); if (!(this.indexPattern && this.indexPattern.title)) { - return false; + return []; } - const { matchingIndexes } = await getMatchingIndexes(this.indexPattern.title); - if (!matchingIndexes) { - return false; + let success; + let matchingIndexes; + try { + ({ success, matchingIndexes } = await getMatchingIndexes(this.indexPattern.title)); + } catch (e) { + // Fail silently } + return success ? matchingIndexes : []; + } + + async supportsFeatureEditing(): Promise { + const matchingIndexes = await this.getSourceIndexList(); // For now we only support 1:1 index-pattern:index matches return matchingIndexes.length === 1; } @@ -749,17 +757,36 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye return MVT_SOURCE_LAYER_NAME; } + async _getEditableIndex(): Promise { + const indexList = await this.getSourceIndexList(); + if (indexList.length === 0) { + throw new Error( + i18n.translate('xpack.maps.source.esSearch.indexZeroLengthEditError', { + defaultMessage: `Your index pattern doesn't point to any indices.`, + }) + ); + } + if (indexList.length > 1) { + throw new Error( + i18n.translate('xpack.maps.source.esSearch.indexOverOneLengthEditError', { + defaultMessage: `Your index pattern points to multiple indices. Only one index is allowed per index pattern.`, + }) + ); + } + return indexList[0]; + } + async addFeature( geometry: Geometry | Position[], defaultFields: Record> ) { - const indexPattern = await this.getIndexPattern(); - await addFeatureToIndex(indexPattern.title, geometry, this.getGeoFieldName(), defaultFields); + const index = await this._getEditableIndex(); + await addFeatureToIndex(index, geometry, this.getGeoFieldName(), defaultFields); } async deleteFeature(featureId: string) { - const indexPattern = await this.getIndexPattern(); - await deleteFeatureFromIndex(indexPattern.title, featureId); + const index = await this._getEditableIndex(); + await deleteFeatureFromIndex(index, featureId); } async getUrlTemplateWithMeta( diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/util/feature_edit.ts b/x-pack/plugins/maps/public/classes/sources/es_search_source/util/feature_edit.ts index c9a967bea3e2c..08ba33a72363f 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/util/feature_edit.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/util/feature_edit.ts @@ -43,8 +43,9 @@ export const deleteFeatureFromIndex = async (indexName: string, featureId: strin export const getMatchingIndexes = async (indexPattern: string) => { return await getHttp().fetch({ - path: `${GET_MATCHING_INDEXES_PATH}/${indexPattern}`, + path: GET_MATCHING_INDEXES_PATH, method: 'GET', + query: { indexPattern }, }); }; diff --git a/x-pack/plugins/maps/server/data_indexing/get_indexes_matching_pattern.ts b/x-pack/plugins/maps/server/data_indexing/get_indexes_matching_pattern.ts index c8b55ffe2e087..e09063f99ec8c 100644 --- a/x-pack/plugins/maps/server/data_indexing/get_indexes_matching_pattern.ts +++ b/x-pack/plugins/maps/server/data_indexing/get_indexes_matching_pattern.ts @@ -5,13 +5,14 @@ * 2.0. */ -import { IScopedClusterClient } from 'kibana/server'; -import { MatchingIndexesResp } from '../../common'; +import { IScopedClusterClient, KibanaResponseFactory, Logger } from 'kibana/server'; export async function getMatchingIndexes( indexPattern: string, - { asCurrentUser }: IScopedClusterClient -): Promise { + { asCurrentUser }: IScopedClusterClient, + response: KibanaResponseFactory, + logger: Logger +) { try { const { body: indexResults } = await asCurrentUser.cat.indices({ index: indexPattern, @@ -20,14 +21,20 @@ export async function getMatchingIndexes( const matchingIndexes = indexResults .map((indexRecord) => indexRecord.index) .filter((indexName) => !!indexName); - return { - success: true, - matchingIndexes: matchingIndexes as string[], - }; + return response.ok({ body: { success: true, matchingIndexes: matchingIndexes as string[] } }); } catch (error) { - return { - success: false, - error, - }; + const errorStatusCode = error.meta?.statusCode; + if (errorStatusCode === 404) { + return response.ok({ body: { success: true, matchingIndexes: [] } }); + } else { + logger.error(error); + return response.custom({ + body: { + success: false, + message: `Error accessing indexes: ${error.meta?.body?.error?.type}`, + }, + statusCode: 200, + }); + } } } diff --git a/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts b/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts index 52dd1c56d2435..baba176286ee2 100644 --- a/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts +++ b/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts @@ -163,19 +163,20 @@ export function initIndexingRoutes({ router.get( { - path: `${GET_MATCHING_INDEXES_PATH}/{indexPattern}`, + path: GET_MATCHING_INDEXES_PATH, validate: { - params: schema.object({ + query: schema.object({ indexPattern: schema.string(), }), }, }, async (context, request, response) => { - const result = await getMatchingIndexes( - request.params.indexPattern, - context.core.elasticsearch.client + return await getMatchingIndexes( + request.query.indexPattern, + context.core.elasticsearch.client, + response, + logger ); - return response.ok({ body: result }); } ); diff --git a/x-pack/test/api_integration/apis/maps/get_indexes_matching_pattern.js b/x-pack/test/api_integration/apis/maps/get_indexes_matching_pattern.js index 4129fdd02b72b..0ff491d8fd672 100644 --- a/x-pack/test/api_integration/apis/maps/get_indexes_matching_pattern.js +++ b/x-pack/test/api_integration/apis/maps/get_indexes_matching_pattern.js @@ -13,7 +13,7 @@ export default function ({ getService }) { describe('get matching index patterns', () => { it('should return an array containing indexes matching pattern', async () => { const resp = await supertest - .get(`/api/maps/getMatchingIndexes/geo_shapes`) + .get(`/api/maps/getMatchingIndexes?indexPattern=geo_shapes`) .set('kbn-xsrf', 'kibana') .send() .expect(200); @@ -24,12 +24,13 @@ export default function ({ getService }) { it('should return an empty array when no indexes match pattern', async () => { const resp = await supertest - .get(`/api/maps/getMatchingIndexes/notAnIndex`) + .get(`/api/maps/getMatchingIndexes?indexPattern=notAnIndex`) .set('kbn-xsrf', 'kibana') .send() .expect(200); - expect(resp.body.success).to.be(false); + expect(resp.body.success).to.be(true); + expect(resp.body.matchingIndexes.length).to.be(0); }); }); } From 8bcbc2dabdd98371933b9dbaafa369e0b9b6774f Mon Sep 17 00:00:00 2001 From: Katrin Freihofner Date: Tue, 31 Aug 2021 17:12:15 +0200 Subject: [PATCH 02/18] increases contrast of recovered health badge (#110210) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../observability/public/pages/alerts/render_cell_value.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx index 0430c750c8862..7e33b61c9b35d 100644 --- a/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx @@ -93,7 +93,7 @@ export const getRenderCellValue = ({ case ALERT_STATUS_RECOVERED: return ( - + {i18n.translate('xpack.observability.alertsTGrid.statusRecoveredDescription', { defaultMessage: 'Recovered', })} From 257cdddc5f2461e6d35ec4b940237f58800a0209 Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Tue, 31 Aug 2021 17:13:58 +0200 Subject: [PATCH 03/18] Increase timeout for displaying welcome interstitial for new users (#110498) --- src/plugins/home/public/application/components/home.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/home/public/application/components/home.tsx b/src/plugins/home/public/application/components/home.tsx index 0572d7b80f986..d398311d30255 100644 --- a/src/plugins/home/public/application/components/home.tsx +++ b/src/plugins/home/public/application/components/home.tsx @@ -87,7 +87,7 @@ export class Home extends Component { if (this.state.isLoading) { this.setState({ isWelcomeEnabled: false }); } - }, 500); + }, 10000); const hasUserIndexPattern = await this.props.hasUserIndexPattern(); From 192556ef63af3cbc4c4dae72a600b426496bd2d1 Mon Sep 17 00:00:00 2001 From: Georgii Gorbachev Date: Tue, 31 Aug 2021 17:40:06 +0200 Subject: [PATCH 04/18] [RAC][Rule Registry] Put index upgrade logic under a feature flag (#110592) **Ticket:** https://github.com/elastic/kibana/issues/110594 ## Summary This PR adds a feature flag around the logic that finds existing Alerts as Data indices and upgrades the mappings or rolls the index if the mappings can't be upgraded in place. **IMPORTANT:** - **The feature flag is switched off by default**. This is intentional, because we need to **disable the upgrade logic in 7.15.0**. - **This is a temporary measure**. We're going to work on fixing the index upgrade logic asap and ship it before the next release that makes any mapping changes, possibly as soon as 7.15.1. - Developers will need to enable it in their local kibana configs this way: ```yaml xpack.ruleRegistry.unsafe.indexUpgrade.enabled: true ``` Please check the ticket for the background of this fix. ### Checklist Delete any items that are not applicable to this PR. - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/master/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) --- x-pack/plugins/rule_registry/server/config.ts | 3 +++ x-pack/plugins/rule_registry/server/plugin.ts | 1 + .../server/rule_data_plugin_service/resource_installer.ts | 8 ++++++-- .../rule_data_plugin_service/rule_data_plugin_service.ts | 2 ++ 4 files changed, 12 insertions(+), 2 deletions(-) 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')); From 50a95ff78a5d277adafba8b139ed04629f8172c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ester=20Mart=C3=AD=20Vilaseca?= Date: Tue, 31 Aug 2021 17:44:34 +0200 Subject: [PATCH 05/18] [Stack Monitoring] Add overview page first version (#110486) * Add header to page template * add external config provider and overview content * REmove unnecessary todos * Remove non working section from header Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../application/external_config_context.tsx | 17 ++++ .../application/global_state_context.tsx | 1 + .../public/application/hooks/use_clusters.ts | 2 +- .../monitoring/public/application/index.tsx | 78 +++++++++++-------- .../pages/cluster/overview_page.tsx | 63 +++++++++++++++ .../application/pages/page_template.tsx | 62 ++++++++++++++- .../components/cluster/overview/index.d.ts | 8 ++ x-pack/plugins/monitoring/public/plugin.ts | 2 +- 8 files changed, 195 insertions(+), 38 deletions(-) create mode 100644 x-pack/plugins/monitoring/public/application/external_config_context.tsx create mode 100644 x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx create mode 100644 x-pack/plugins/monitoring/public/components/cluster/overview/index.d.ts 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) => { From 31642cbf0a7f831a09756b43d1eca566cb6c21f2 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Tue, 31 Aug 2021 11:45:25 -0400 Subject: [PATCH 06/18] [CI] Disable baseline_trigger job (#110659) --- .ci/Jenkinsfile_baseline_trigger | 2 ++ 1 file changed, 2 insertions(+) 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 From 77b8e25b98435dbbbfacc757464603b0f298be66 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Tue, 31 Aug 2021 09:48:10 -0600 Subject: [PATCH 07/18] [RAC][Security Solution] Adds Machine Learning rule type (#108612) ## Summary Ports over the existing Security Solution ML Rule to the RuleRegistry. How to test this implementation 1. Enable the following in your `kibana.dev.yml` ``` xpack.ruleRegistry.enabled: true xpack.ruleRegistry.write.enabled: true xpack.securitySolution.enableExperimental: ['ruleRegistryEnabled'] ``` 2. Create a rule by running: ``` ./x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/scripts/create_rule_ml.sh ``` 3. Push document to anomalies index (or trigger anomaly for job id from `create_rule_ml.sh` script) ### Checklist Delete any items that are not applicable to this PR. - [X] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../rule_types/__mocks__/rule_type.ts | 2 + .../lib/detection_engine/rule_types/index.ts | 1 + .../ml/create_ml_alert_type.test.ts | 112 ++++++++++++++++++ .../rule_types/ml/create_ml_alert_type.ts | 85 +++++++++++++ .../scripts/create_rule_indicator_match.sh | 0 .../rule_types/scripts/create_rule_ml.sh | 53 +++++++++ .../lib/detection_engine/rule_types/types.ts | 3 +- .../security_solution/server/plugin.ts | 7 +- 8 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts mode change 100644 => 100755 x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/scripts/create_rule_indicator_match.sh create mode 100755 x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/scripts/create_rule_ml.sh 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/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 Date: Tue, 31 Aug 2021 17:59:51 +0200 Subject: [PATCH 08/18] [Expressions] Fix flaky test checking execution duration (#110338) --- src/plugins/expressions/common/execution/execution.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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); } }); From 3bae4cdc06ab89819d4afe6960562a2e3b2e3551 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Tue, 31 Aug 2021 11:10:54 -0500 Subject: [PATCH 09/18] Add inspector panel for APM routes (#109696) When the observability:enableInspectEsQueries advanced setting is enabled, show an inspector that includes all queries through useFetcher. Remove the callout. --- .../common/adapters/request/index.ts | 2 +- .../adapters/request/request_adapter.ts | 11 +- src/plugins/inspector/common/index.ts | 1 + x-pack/plugins/apm/kibana.json | 1 + .../plugins/apm/public/application/index.tsx | 1 + .../plugins/apm/public/application/uxApp.tsx | 3 +- .../public/components/routing/app_root.tsx | 13 +- .../shared/apm_header_action_menu/index.tsx | 2 + .../inspector_header_link.tsx | 39 ++++ .../public/components/shared/search_bar.tsx | 56 +----- .../context/apm_plugin/apm_plugin_context.tsx | 2 + .../context/inspector/inspector_context.tsx | 82 +++++++++ .../inspector/use_inspector_context.tsx | 13 ++ .../plugins/apm/public/hooks/use_fetcher.tsx | 10 + x-pack/plugins/apm/public/plugin.ts | 9 +- .../public/services/rest/createCallApmApi.ts | 2 +- x-pack/plugins/apm/server/index.ts | 2 +- .../create_es_client/call_async_with_debug.ts | 33 ++-- .../create_es_client/get_inspect_response.ts | 171 ++++++++++++++++++ .../server/routes/register_routes/index.ts | 7 +- x-pack/plugins/apm/server/routes/typings.ts | 9 - x-pack/plugins/apm/tsconfig.json | 1 + x-pack/plugins/apm/typings/common.d.ts | 3 + .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../tests/inspect/inspect.ts | 10 +- 26 files changed, 382 insertions(+), 107 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/apm_header_action_menu/inspector_header_link.tsx create mode 100644 x-pack/plugins/apm/public/context/inspector/inspector_context.tsx create mode 100644 x-pack/plugins/apm/public/context/inspector/use_inspector_context.tsx create mode 100644 x-pack/plugins/apm/server/lib/helpers/create_es_client/get_inspect_response.ts 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/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/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/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/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..a329ad57e2b33 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; 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/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/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/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/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1350b9d799a7c..a41e0695a1bd7 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": "エラー", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 85889a4094036..8d2e3607d1d32 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": "错误", 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', ]); }); }); From 3f7c461cd5516b525d9cb37ccc3f03e921477ec9 Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Tue, 31 Aug 2021 19:26:45 +0300 Subject: [PATCH 10/18] [Graph] Deangularize graph app controller (#106587) * [Graph] deaungularize control panel * [Graph] move main graph directive to react * [Graph] refactoring * [Graph] remove redundant memoization, update import * [Graph] fix settings menu, clean up the code * [Graph] fix graph settings * [Graph] code refactoring, fixing control panel render issues * [Graph] fix small mistake * [Graph] rename components * [Graph] fix imports * [Graph] fix graph search and inspect panel * [Graph] remove redundant types * [Graph] fix problem with selection list * [Graph] fix functional test which uses selection list * [Graph] fix unit tests, update types * [Graph] fix types * [Discover] fix url queries * [Graph] fix types * [Graph] add react router, remove angular stuff * [Graph] fix styles * [Graph] fix i18n * [Graph] fix navigation to a new workspace creation * [Graph] fix issues from comments * [Graph] add suggested changed * Update x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx Co-authored-by: Marco Liberati * [Graph] remove brace lib from imports * [Graph] fix url navigation between workspaces, fix types * [Graph] refactoring, fixing url issue * [Graph] update graph dependencies * [Graph] add comments * [Graph] fix types * [Graph] fix new button, fix control panel styles * [Graph] apply suggestions Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Marco Liberati --- x-pack/plugins/graph/public/_main.scss | 1 + .../public/angular/templates/_index.scss | 3 - .../graph/public/angular/templates/index.html | 362 ---------- .../angular/templates/listing_ng_wrapper.html | 13 - x-pack/plugins/graph/public/app.js | 646 ------------------ x-pack/plugins/graph/public/application.ts | 120 ++-- .../listing.tsx => apps/listing_route.tsx} | 98 ++- .../graph/public/apps/workspace_route.tsx | 152 +++++ x-pack/plugins/graph/public/badge.js | 24 - .../templates => components}/_graph.scss | 8 - .../graph/public/components/_index.scss | 3 + .../templates => components}/_inspect.scss | 0 .../templates => components}/_sidebar.scss | 22 +- .../plugins/graph/public/components/app.tsx | 76 --- .../control_panel/control_panel.tsx | 143 ++++ .../control_panel/control_panel_tool_bar.tsx | 230 +++++++ .../control_panel/drill_down_icon_links.tsx | 61 ++ .../components/control_panel/drill_downs.tsx | 55 ++ .../public/components/control_panel/index.ts | 8 + .../control_panel/merge_candidates.tsx | 137 ++++ .../components/control_panel/select_style.tsx | 45 ++ .../control_panel/selected_node_editor.tsx | 100 +++ .../control_panel/selected_node_item.tsx | 63 ++ .../control_panel/selection_tool_bar.tsx | 136 ++++ .../_graph_visualization.scss | 8 + .../graph_visualization.test.tsx | 100 ++- .../graph_visualization.tsx | 69 +- .../graph/public/components/inspect_panel.tsx | 99 +++ .../inspect_panel/inspect_panel.tsx | 109 --- .../public/components/search_bar.test.tsx | 49 +- .../graph/public/components/search_bar.tsx | 44 +- .../settings/advanced_settings_form.tsx | 5 +- .../components/settings/blocklist_form.tsx | 19 +- .../components/settings/settings.test.tsx | 18 +- .../public/components/settings/settings.tsx | 66 +- .../components/settings/url_template_list.tsx | 4 +- .../components/workspace_layout/index.ts | 8 + .../workspace_layout/workspace_layout.tsx | 234 +++++++ .../workspace_top_nav_menu.tsx | 175 +++++ .../graph/public/helpers/as_observable.ts | 16 +- .../public/helpers/saved_workspace_utils.ts | 2 +- .../graph/public/helpers/use_graph_loader.ts | 108 +++ .../public/helpers/use_workspace_loader.ts | 120 ++++ x-pack/plugins/graph/public/index.scss | 1 - x-pack/plugins/graph/public/plugin.ts | 3 +- x-pack/plugins/graph/public/router.tsx | 33 + .../services/persistence/deserialize.test.ts | 2 +- .../services/persistence/serialize.test.ts | 4 +- .../public/services/persistence/serialize.ts | 7 +- .../graph/public/services/save_modal.tsx | 4 +- .../workspace}/graph_client_workspace.d.ts | 0 .../workspace}/graph_client_workspace.js | 6 +- .../workspace}/graph_client_workspace.test.js | 0 .../state_management/advanced_settings.ts | 4 +- .../state_management/datasource.sagas.ts | 4 +- .../graph/public/state_management/fields.ts | 11 +- .../public/state_management/legacy.test.ts | 8 +- .../graph/public/state_management/mocks.ts | 17 +- .../state_management/persistence.test.ts | 24 +- .../public/state_management/persistence.ts | 48 +- .../graph/public/state_management/store.ts | 32 +- .../public/state_management/url_templates.ts | 8 +- .../public/state_management/workspace.ts | 80 ++- .../plugins/graph/public/types/persistence.ts | 6 +- .../graph/public/types/workspace_state.ts | 66 +- 65 files changed, 2521 insertions(+), 1606 deletions(-) delete mode 100644 x-pack/plugins/graph/public/angular/templates/_index.scss delete mode 100644 x-pack/plugins/graph/public/angular/templates/index.html delete mode 100644 x-pack/plugins/graph/public/angular/templates/listing_ng_wrapper.html delete mode 100644 x-pack/plugins/graph/public/app.js rename x-pack/plugins/graph/public/{components/listing.tsx => apps/listing_route.tsx} (64%) create mode 100644 x-pack/plugins/graph/public/apps/workspace_route.tsx delete mode 100644 x-pack/plugins/graph/public/badge.js rename x-pack/plugins/graph/public/{angular/templates => components}/_graph.scss (75%) rename x-pack/plugins/graph/public/{angular/templates => components}/_inspect.scss (100%) rename x-pack/plugins/graph/public/{angular/templates => components}/_sidebar.scss (82%) delete mode 100644 x-pack/plugins/graph/public/components/app.tsx create mode 100644 x-pack/plugins/graph/public/components/control_panel/control_panel.tsx create mode 100644 x-pack/plugins/graph/public/components/control_panel/control_panel_tool_bar.tsx create mode 100644 x-pack/plugins/graph/public/components/control_panel/drill_down_icon_links.tsx create mode 100644 x-pack/plugins/graph/public/components/control_panel/drill_downs.tsx create mode 100644 x-pack/plugins/graph/public/components/control_panel/index.ts create mode 100644 x-pack/plugins/graph/public/components/control_panel/merge_candidates.tsx create mode 100644 x-pack/plugins/graph/public/components/control_panel/select_style.tsx create mode 100644 x-pack/plugins/graph/public/components/control_panel/selected_node_editor.tsx create mode 100644 x-pack/plugins/graph/public/components/control_panel/selected_node_item.tsx create mode 100644 x-pack/plugins/graph/public/components/control_panel/selection_tool_bar.tsx create mode 100644 x-pack/plugins/graph/public/components/inspect_panel.tsx delete mode 100644 x-pack/plugins/graph/public/components/inspect_panel/inspect_panel.tsx create mode 100644 x-pack/plugins/graph/public/components/workspace_layout/index.ts create mode 100644 x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.tsx create mode 100644 x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx create mode 100644 x-pack/plugins/graph/public/helpers/use_graph_loader.ts create mode 100644 x-pack/plugins/graph/public/helpers/use_workspace_loader.ts create mode 100644 x-pack/plugins/graph/public/router.tsx rename x-pack/plugins/graph/public/{angular => services/workspace}/graph_client_workspace.d.ts (100%) rename x-pack/plugins/graph/public/{angular => services/workspace}/graph_client_workspace.js (99%) rename x-pack/plugins/graph/public/{angular => services/workspace}/graph_client_workspace.test.js (100%) 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/graph/public/components/control_panel/index.ts b/x-pack/plugins/graph/public/components/control_panel/index.ts new file mode 100644 index 0000000000000..7c3ab15baea2d --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './control_panel'; diff --git a/x-pack/plugins/graph/public/components/control_panel/merge_candidates.tsx b/x-pack/plugins/graph/public/components/control_panel/merge_candidates.tsx new file mode 100644 index 0000000000000..cc380993ef996 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/merge_candidates.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiToolTip } from '@elastic/eui'; +import { ControlType, TermIntersect, Workspace } from '../../types'; +import { VennDiagram } from '../venn_diagram'; + +interface MergeCandidatesProps { + workspace: Workspace; + mergeCandidates: TermIntersect[]; + onSetControl: (control: ControlType) => void; +} + +export const MergeCandidates = ({ + workspace, + mergeCandidates, + onSetControl, +}: MergeCandidatesProps) => { + const performMerge = (parentId: string, childId: string) => { + const tempMergeCandidates = [...mergeCandidates]; + let found = true; + while (found) { + found = false; + + for (let i = 0; i < tempMergeCandidates.length; i++) { + const term = tempMergeCandidates[i]; + if (term.id1 === childId || term.id2 === childId) { + tempMergeCandidates.splice(i, 1); + found = true; + break; + } + } + } + workspace.mergeIds(parentId, childId); + onSetControl('none'); + }; + + return ( +
+
+ + {i18n.translate('xpack.graph.sidebar.linkSummaryTitle', { + defaultMessage: 'Link summary', + })} +
+ {mergeCandidates.map((mc) => { + const mergeTerm1ToTerm2ButtonMsg = i18n.translate( + 'xpack.graph.sidebar.linkSummary.mergeTerm1ToTerm2ButtonTooltip', + { + defaultMessage: 'Merge {term1} into {term2}', + values: { term1: mc.term1, term2: mc.term2 }, + } + ); + const mergeTerm2ToTerm1ButtonMsg = i18n.translate( + 'xpack.graph.sidebar.linkSummary.mergeTerm2ToTerm1ButtonTooltip', + { + defaultMessage: 'Merge {term2} into {term1}', + values: { term1: mc.term1, term2: mc.term2 }, + } + ); + const leftTermCountMsg = i18n.translate( + 'xpack.graph.sidebar.linkSummary.leftTermCountTooltip', + { + defaultMessage: '{count} documents have term {term}', + values: { count: mc.v1, term: mc.term1 }, + } + ); + const bothTermsCountMsg = i18n.translate( + 'xpack.graph.sidebar.linkSummary.bothTermsCountTooltip', + { + defaultMessage: '{count} documents have both terms', + values: { count: mc.overlap }, + } + ); + const rightTermCountMsg = i18n.translate( + 'xpack.graph.sidebar.linkSummary.rightTermCountTooltip', + { + defaultMessage: '{count} documents have term {term}', + values: { count: mc.v2, term: mc.term2 }, + } + ); + + const onMergeTerm1ToTerm2Click = () => performMerge(mc.id2, mc.id1); + const onMergeTerm2ToTerm1Click = () => performMerge(mc.id1, mc.id2); + + return ( +
+ + + + + + {mc.term1} + {mc.term2} + + + + + + + + + + {mc.v1} + + +  ({mc.overlap})  + + + {mc.v2} + +
+ ); + })} +
+ ); +}; diff --git a/x-pack/plugins/graph/public/components/control_panel/select_style.tsx b/x-pack/plugins/graph/public/components/control_panel/select_style.tsx new file mode 100644 index 0000000000000..2dbefc7d24459 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/select_style.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { Workspace } from '../../types'; + +interface SelectStyleProps { + workspace: Workspace; + colors: string[]; +} + +export const SelectStyle = ({ colors, workspace }: SelectStyleProps) => { + return ( +
+
+ + {i18n.translate('xpack.graph.sidebar.styleVerticesTitle', { + defaultMessage: 'Style selected vertices', + })} +
+ +
+ {colors.map((c) => { + const onSelectColor = () => { + workspace.colorSelected(c); + workspace.changeHandler(); + }; + return ( +
+
+ ); +}; diff --git a/x-pack/plugins/graph/public/components/control_panel/selected_node_editor.tsx b/x-pack/plugins/graph/public/components/control_panel/selected_node_editor.tsx new file mode 100644 index 0000000000000..a0eed56fac672 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/selected_node_editor.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Workspace, WorkspaceNode } from '../../types'; + +interface SelectedNodeEditorProps { + workspace: Workspace; + selectedNode: WorkspaceNode; +} + +export const SelectedNodeEditor = ({ workspace, selectedNode }: SelectedNodeEditorProps) => { + const groupButtonMsg = i18n.translate('xpack.graph.sidebar.groupButtonTooltip', { + defaultMessage: 'group the currently selected items into {latestSelectionLabel}', + values: { latestSelectionLabel: selectedNode.label }, + }); + const ungroupButtonMsg = i18n.translate('xpack.graph.sidebar.ungroupButtonTooltip', { + defaultMessage: 'ungroup {latestSelectionLabel}', + values: { latestSelectionLabel: selectedNode.label }, + }); + + const onGroupButtonClick = () => { + workspace.groupSelections(selectedNode); + }; + const onClickUngroup = () => { + workspace.ungroup(selectedNode); + }; + const onChangeSelectedVertexLabel = (event: React.ChangeEvent) => { + selectedNode.label = event.target.value; + workspace.changeHandler(); + }; + + return ( +
+
+ {selectedNode.icon && } + {selectedNode.data.field} {selectedNode.data.term} +
+ + {(workspace.selectedNodes.length > 1 || + (workspace.selectedNodes.length > 0 && workspace.selectedNodes[0] !== selectedNode)) && ( + + + + )} + + {selectedNode.numChildren > 0 && ( + + + + )} + +
+
+ +
+ element && (element.value = selectedNode.label)} + type="text" + id="labelEdit" + className="form-control input-sm" + onChange={onChangeSelectedVertexLabel} + /> +
+ {i18n.translate('xpack.graph.sidebar.displayLabelHelpText', { + defaultMessage: 'Change the label for this vertex.', + })} +
+
+
+
+
+ ); +}; diff --git a/x-pack/plugins/graph/public/components/control_panel/selected_node_item.tsx b/x-pack/plugins/graph/public/components/control_panel/selected_node_item.tsx new file mode 100644 index 0000000000000..11df3b5d52086 --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/selected_node_item.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { hexToRgb, isColorDark } from '@elastic/eui'; +import classNames from 'classnames'; +import React from 'react'; +import { WorkspaceNode } from '../../types'; + +const isHexColorDark = (color: string) => isColorDark(...hexToRgb(color)); + +interface SelectedNodeItemProps { + node: WorkspaceNode; + isHighlighted: boolean; + onDeselectNode: (node: WorkspaceNode) => void; + onSelectedFieldClick: (node: WorkspaceNode) => void; +} + +export const SelectedNodeItem = ({ + node, + isHighlighted, + onSelectedFieldClick, + onDeselectNode, +}: SelectedNodeItemProps) => { + const fieldClasses = classNames('gphSelectionList__field', { + ['gphSelectionList__field--selected']: isHighlighted, + }); + const fieldIconClasses = classNames('fa', 'gphNode__text', 'gphSelectionList__icon', { + ['gphNode__text--inverse']: isHexColorDark(node.color), + }); + + return ( + + ); +}; diff --git a/x-pack/plugins/graph/public/components/control_panel/selection_tool_bar.tsx b/x-pack/plugins/graph/public/components/control_panel/selection_tool_bar.tsx new file mode 100644 index 0000000000000..e2e9771a8e9ef --- /dev/null +++ b/x-pack/plugins/graph/public/components/control_panel/selection_tool_bar.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { ControlType, Workspace } from '../../types'; + +interface SelectionToolBarProps { + workspace: Workspace; + onSetControl: (data: ControlType) => void; +} + +export const SelectionToolBar = ({ workspace, onSetControl }: SelectionToolBarProps) => { + const haveNodes = workspace.nodes.length === 0; + + const selectAllButtonMsg = i18n.translate( + 'xpack.graph.sidebar.selections.selectAllButtonTooltip', + { + defaultMessage: 'Select all', + } + ); + const selectNoneButtonMsg = i18n.translate( + 'xpack.graph.sidebar.selections.selectNoneButtonTooltip', + { + defaultMessage: 'Select none', + } + ); + const invertSelectionButtonMsg = i18n.translate( + 'xpack.graph.sidebar.selections.invertSelectionButtonTooltip', + { + defaultMessage: 'Invert selection', + } + ); + const selectNeighboursButtonMsg = i18n.translate( + 'xpack.graph.sidebar.selections.selectNeighboursButtonTooltip', + { + defaultMessage: 'Select neighbours', + } + ); + + const onSelectAllClick = () => { + onSetControl('none'); + workspace.selectAll(); + workspace.changeHandler(); + }; + const onSelectNoneClick = () => { + onSetControl('none'); + workspace.selectNone(); + workspace.changeHandler(); + }; + const onInvertSelectionClick = () => { + onSetControl('none'); + workspace.selectInvert(); + workspace.changeHandler(); + }; + const onSelectNeighboursClick = () => { + onSetControl('none'); + workspace.selectNeighbours(); + workspace.changeHandler(); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/graph/public/components/graph_visualization/_graph_visualization.scss b/x-pack/plugins/graph/public/components/graph_visualization/_graph_visualization.scss index caef2b6987ddd..0853ab4114595 100644 --- a/x-pack/plugins/graph/public/components/graph_visualization/_graph_visualization.scss +++ b/x-pack/plugins/graph/public/components/graph_visualization/_graph_visualization.scss @@ -1,3 +1,11 @@ +@mixin gphSvgText() { + font-family: $euiFontFamily; + font-size: $euiSizeS; + line-height: $euiSizeM; + fill: $euiColorDarkShade; + color: $euiColorDarkShade; +} + .gphVisualization { flex: 1; display: flex; diff --git a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.test.tsx b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.test.tsx index f49b5bfd32da8..1ae556a79edcb 100644 --- a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.test.tsx +++ b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.test.tsx @@ -7,15 +7,13 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { - GraphVisualization, - GroupAwareWorkspaceNode, - GroupAwareWorkspaceEdge, -} from './graph_visualization'; +import { GraphVisualization } from './graph_visualization'; +import { Workspace, WorkspaceEdge, WorkspaceNode } from '../../types'; describe('graph_visualization', () => { - const nodes: GroupAwareWorkspaceNode[] = [ + const nodes: WorkspaceNode[] = [ { + id: '1', color: 'black', data: { field: 'A', @@ -37,6 +35,7 @@ describe('graph_visualization', () => { y: 5, }, { + id: '2', color: 'red', data: { field: 'B', @@ -58,6 +57,7 @@ describe('graph_visualization', () => { y: 9, }, { + id: '3', color: 'yellow', data: { field: 'C', @@ -79,7 +79,7 @@ describe('graph_visualization', () => { y: 9, }, ]; - const edges: GroupAwareWorkspaceEdge[] = [ + const edges: WorkspaceEdge[] = [ { isSelected: true, label: '', @@ -101,9 +101,32 @@ describe('graph_visualization', () => { width: 2.2, }, ]; + const workspace = ({ + nodes, + edges, + selectNone: () => {}, + changeHandler: jest.fn(), + toggleNodeSelection: jest.fn().mockImplementation((node: WorkspaceNode) => { + return !node.isSelected; + }), + getAllIntersections: jest.fn(), + } as unknown) as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render empty workspace without data', () => { - expect(shallow( {}} nodeClick={() => {}} />)) - .toMatchInlineSnapshot(` + expect( + shallow( + {}} + onSetControl={() => {}} + onSetMergeCandidates={() => {}} + /> + ) + ).toMatchInlineSnapshot(` { it('should render to svg elements', () => { expect( shallow( - {}} nodeClick={() => {}} nodes={nodes} edges={edges} /> + {}} + onSetControl={() => {}} + onSetMergeCandidates={() => {}} + /> ) ).toMatchSnapshot(); }); - it('should react to node click', () => { - const nodeClickSpy = jest.fn(); + it('should react to node selection', () => { + const selectSelectedMock = jest.fn(); + const instance = shallow( {}} - nodeClick={nodeClickSpy} - nodes={nodes} - edges={edges} + workspace={workspace} + selectSelected={selectSelectedMock} + onSetControl={() => {}} + onSetMergeCandidates={() => {}} /> ); + + instance.find('.gphNode').last().simulate('click', {}); + + expect(workspace.toggleNodeSelection).toHaveBeenCalledWith(nodes[2]); + expect(selectSelectedMock).toHaveBeenCalledWith(nodes[2]); + expect(workspace.changeHandler).toHaveBeenCalled(); + }); + + it('should react to node deselection', () => { + const onSetControlMock = jest.fn(); + const instance = shallow( + {}} + onSetControl={onSetControlMock} + onSetMergeCandidates={() => {}} + /> + ); + instance.find('.gphNode').first().simulate('click', {}); - expect(nodeClickSpy).toHaveBeenCalledWith(nodes[0], {}); + + expect(workspace.toggleNodeSelection).toHaveBeenCalledWith(nodes[0]); + expect(onSetControlMock).toHaveBeenCalledWith('none'); + expect(workspace.changeHandler).toHaveBeenCalled(); }); it('should react to edge click', () => { - const edgeClickSpy = jest.fn(); const instance = shallow( {}} - nodes={nodes} - edges={edges} + workspace={workspace} + selectSelected={() => {}} + onSetControl={() => {}} + onSetMergeCandidates={() => {}} /> ); + instance.find('.gphEdge').first().simulate('click'); - expect(edgeClickSpy).toHaveBeenCalledWith(edges[0]); + + expect(workspace.getAllIntersections).toHaveBeenCalled(); + expect(edges[0].topSrc).toEqual(workspace.getAllIntersections.mock.calls[0][1][0]); + expect(edges[0].topTarget).toEqual(workspace.getAllIntersections.mock.calls[0][1][1]); }); }); diff --git a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx index 9b8dc98b84f47..26359101a9a5b 100644 --- a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx +++ b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx @@ -9,31 +9,14 @@ import React, { useRef } from 'react'; import classNames from 'classnames'; import d3, { ZoomEvent } from 'd3'; import { isColorDark, hexToRgb } from '@elastic/eui'; -import { WorkspaceNode, WorkspaceEdge } from '../../types'; +import { Workspace, WorkspaceNode, TermIntersect, ControlType, WorkspaceEdge } from '../../types'; import { makeNodeId } from '../../services/persistence'; -/* - * The layouting algorithm sets a few extra properties on - * node objects to handle grouping. This will be moved to - * a separate data structure when the layouting is migrated - */ - -export interface GroupAwareWorkspaceNode extends WorkspaceNode { - kx: number; - ky: number; - numChildren: number; -} - -export interface GroupAwareWorkspaceEdge extends WorkspaceEdge { - topTarget: GroupAwareWorkspaceNode; - topSrc: GroupAwareWorkspaceNode; -} - export interface GraphVisualizationProps { - nodes?: GroupAwareWorkspaceNode[]; - edges?: GroupAwareWorkspaceEdge[]; - edgeClick: (edge: GroupAwareWorkspaceEdge) => void; - nodeClick: (node: GroupAwareWorkspaceNode, e: React.MouseEvent) => void; + workspace: Workspace; + onSetControl: (control: ControlType) => void; + selectSelected: (node: WorkspaceNode) => void; + onSetMergeCandidates: (terms: TermIntersect[]) => void; } function registerZooming(element: SVGSVGElement) { @@ -55,13 +38,39 @@ function registerZooming(element: SVGSVGElement) { } export function GraphVisualization({ - nodes, - edges, - edgeClick, - nodeClick, + workspace, + selectSelected, + onSetControl, + onSetMergeCandidates, }: GraphVisualizationProps) { const svgRoot = useRef(null); + const nodeClick = (n: WorkspaceNode, event: React.MouseEvent) => { + // Selection logic - shift key+click helps selects multiple nodes + // Without the shift key we deselect all prior selections (perhaps not + // a great idea for touch devices with no concept of shift key) + if (!event.shiftKey) { + const prevSelection = n.isSelected; + workspace.selectNone(); + n.isSelected = prevSelection; + } + if (workspace.toggleNodeSelection(n)) { + selectSelected(n); + } else { + onSetControl('none'); + } + workspace.changeHandler(); + }; + + const handleMergeCandidatesCallback = (termIntersects: TermIntersect[]) => { + const mergeCandidates: TermIntersect[] = [...termIntersects]; + onSetMergeCandidates(mergeCandidates); + onSetControl('mergeTerms'); + }; + + const edgeClick = (edge: WorkspaceEdge) => + workspace.getAllIntersections(handleMergeCandidatesCallback, [edge.topSrc, edge.topTarget]); + return ( - {edges && - edges.map((edge) => ( + {workspace.edges && + workspace.edges.map((edge) => ( ))} - {nodes && - nodes + {workspace.nodes && + workspace.nodes .filter((node) => !node.parent) .map((node) => ( {}; + +export const InspectPanel = ({ + showInspect, + lastRequest, + lastResponse, + indexPattern, +}: InspectPanelProps) => { + const [selectedTabId, setSelectedTabId] = useState('request'); + + const onRequestClick = () => setSelectedTabId('request'); + const onResponseClick = () => setSelectedTabId('response'); + + const editorContent = useMemo(() => (selectedTabId === 'request' ? lastRequest : lastResponse), [ + lastRequest, + lastResponse, + selectedTabId, + ]); + + if (showInspect) { + return ( +
+
+
+ +
+ +
+ + http://host:port/{indexPattern?.id}/_graph/explore + + + + + + + + + + +
+
+
+ ); + } + + return null; +}; diff --git a/x-pack/plugins/graph/public/components/inspect_panel/inspect_panel.tsx b/x-pack/plugins/graph/public/components/inspect_panel/inspect_panel.tsx deleted file mode 100644 index 2f29849bebcec..0000000000000 --- a/x-pack/plugins/graph/public/components/inspect_panel/inspect_panel.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useState } from 'react'; -import { EuiTab, EuiTabs, EuiText } from '@elastic/eui'; -import { monaco, XJsonLang } from '@kbn/monaco'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { IUiSettingsClient } from 'kibana/public'; -import { IndexPattern } from '../../../../../../src/plugins/data/public'; -import { - CodeEditor, - KibanaContextProvider, -} from '../../../../../../src/plugins/kibana_react/public'; - -interface InspectPanelProps { - showInspect?: boolean; - indexPattern?: IndexPattern; - uiSettings: IUiSettingsClient; - lastRequest?: string; - lastResponse?: string; -} - -const CODE_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = { - automaticLayout: true, - fontSize: 12, - lineNumbers: 'on', - minimap: { - enabled: false, - }, - overviewRulerBorder: false, - readOnly: true, - scrollbar: { - alwaysConsumeMouseWheel: false, - }, - scrollBeyondLastLine: false, - wordWrap: 'on', - wrappingIndent: 'indent', -}; - -const dummyCallback = () => {}; - -export const InspectPanel = ({ - showInspect, - lastRequest, - lastResponse, - indexPattern, - uiSettings, -}: InspectPanelProps) => { - const [selectedTabId, setSelectedTabId] = useState('request'); - - const onRequestClick = () => setSelectedTabId('request'); - const onResponseClick = () => setSelectedTabId('response'); - - const services = useMemo(() => ({ uiSettings }), [uiSettings]); - - const editorContent = useMemo(() => (selectedTabId === 'request' ? lastRequest : lastResponse), [ - selectedTabId, - lastRequest, - lastResponse, - ]); - - if (showInspect) { - return ( - -
-
-
- -
- -
- - http://host:port/{indexPattern?.id}/_graph/explore - - - - - - - - - - -
-
-
-
- ); - } - - return null; -}; diff --git a/x-pack/plugins/graph/public/components/search_bar.test.tsx b/x-pack/plugins/graph/public/components/search_bar.test.tsx index 690fdf832c373..1b76cde1a62fb 100644 --- a/x-pack/plugins/graph/public/components/search_bar.test.tsx +++ b/x-pack/plugins/graph/public/components/search_bar.test.tsx @@ -6,18 +6,18 @@ */ import { mountWithIntl } from '@kbn/test/jest'; -import { SearchBar, OuterSearchBarProps } from './search_bar'; -import React, { ReactElement } from 'react'; +import { SearchBar, SearchBarProps } from './search_bar'; +import React, { Component, ReactElement } from 'react'; import { CoreStart } from 'src/core/public'; import { act } from 'react-dom/test-utils'; import { IndexPattern, QueryStringInput } from '../../../../../src/plugins/data/public'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; -import { I18nProvider } from '@kbn/i18n/react'; +import { I18nProvider, InjectedIntl } from '@kbn/i18n/react'; import { openSourceModal } from '../services/source_modal'; -import { GraphStore, setDatasource } from '../state_management'; +import { GraphStore, setDatasource, submitSearchSaga } from '../state_management'; import { ReactWrapper } from 'enzyme'; import { createMockGraphStore } from '../state_management/mocks'; import { Provider } from 'react-redux'; @@ -26,7 +26,7 @@ jest.mock('../services/source_modal', () => ({ openSourceModal: jest.fn() })); const waitForIndexPatternFetch = () => new Promise((r) => setTimeout(r)); -function wrapSearchBarInContext(testProps: OuterSearchBarProps) { +function wrapSearchBarInContext(testProps: SearchBarProps) { const services = { uiSettings: { get: (key: string) => { @@ -67,21 +67,34 @@ function wrapSearchBarInContext(testProps: OuterSearchBarProps) { } describe('search_bar', () => { + let dispatchSpy: jest.Mock; + let instance: ReactWrapper< + SearchBarProps & { intl: InjectedIntl }, + Readonly<{}>, + Component<{}, {}, any> + >; + let store: GraphStore; const defaultProps = { isLoading: false, - onQuerySubmit: jest.fn(), indexPatternProvider: { get: jest.fn(() => Promise.resolve(({ fields: [] } as unknown) as IndexPattern)), }, confirmWipeWorkspace: (callback: () => void) => { callback(); }, + onIndexPatternChange: (indexPattern?: IndexPattern) => { + instance.setProps({ + ...defaultProps, + currentIndexPattern: indexPattern, + }); + }, }; - let instance: ReactWrapper; - let store: GraphStore; beforeEach(() => { - store = createMockGraphStore({}).store; + store = createMockGraphStore({ + sagas: [submitSearchSaga], + }).store; + store.dispatch( setDatasource({ type: 'indexpattern', @@ -89,14 +102,21 @@ describe('search_bar', () => { title: 'test-index', }) ); + + dispatchSpy = jest.fn(store.dispatch); + store.dispatch = dispatchSpy; }); async function mountSearchBar() { jest.clearAllMocks(); - const wrappedSearchBar = wrapSearchBarInContext({ ...defaultProps }); + const searchBarTestRoot = React.createElement((updatedProps: SearchBarProps) => ( + + {wrapSearchBarInContext({ ...defaultProps, ...updatedProps })} + + )); await act(async () => { - instance = mountWithIntl({wrappedSearchBar}); + instance = mountWithIntl(searchBarTestRoot); }); } @@ -119,7 +139,10 @@ describe('search_bar', () => { instance.find('form').simulate('submit', { preventDefault: () => {} }); }); - expect(defaultProps.onQuerySubmit).toHaveBeenCalledWith('testQuery'); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: 'x-pack/graph/workspace/SUBMIT_SEARCH', + payload: 'testQuery', + }); }); it('should translate kql query into JSON dsl', async () => { @@ -135,7 +158,7 @@ describe('search_bar', () => { instance.find('form').simulate('submit', { preventDefault: () => {} }); }); - const parsedQuery = JSON.parse(defaultProps.onQuerySubmit.mock.calls[0][0]); + const parsedQuery = JSON.parse(dispatchSpy.mock.calls[0][0].payload); expect(parsedQuery).toEqual({ bool: { should: [{ match: { test: 'abc' } }], minimum_should_match: 1 }, }); diff --git a/x-pack/plugins/graph/public/components/search_bar.tsx b/x-pack/plugins/graph/public/components/search_bar.tsx index fdf198c761957..fc7e3be3d0d37 100644 --- a/x-pack/plugins/graph/public/components/search_bar.tsx +++ b/x-pack/plugins/graph/public/components/search_bar.tsx @@ -17,6 +17,7 @@ import { datasourceSelector, requestDatasource, IndexpatternDatasource, + submitSearch, } from '../state_management'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; @@ -28,11 +29,11 @@ import { esKuery, } from '../../../../../src/plugins/data/public'; -export interface OuterSearchBarProps { +export interface SearchBarProps { isLoading: boolean; - initialQuery?: string; - onQuerySubmit: (query: string) => void; - + urlQuery: string | null; + currentIndexPattern?: IndexPattern; + onIndexPatternChange: (indexPattern?: IndexPattern) => void; confirmWipeWorkspace: ( onConfirm: () => void, text?: string, @@ -41,9 +42,10 @@ export interface OuterSearchBarProps { indexPatternProvider: IndexPatternProvider; } -export interface SearchBarProps extends OuterSearchBarProps { +export interface SearchBarStateProps { currentDatasource?: IndexpatternDatasource; onIndexPatternSelected: (indexPattern: IndexPatternSavedObject) => void; + submit: (searchTerm: string) => void; } function queryToString(query: Query, indexPattern: IndexPattern) { @@ -65,31 +67,34 @@ function queryToString(query: Query, indexPattern: IndexPattern) { return JSON.stringify(query.query); } -export function SearchBarComponent(props: SearchBarProps) { +export function SearchBarComponent(props: SearchBarStateProps & SearchBarProps) { const { - currentDatasource, - onQuerySubmit, isLoading, - onIndexPatternSelected, - initialQuery, + urlQuery, + currentIndexPattern, + currentDatasource, indexPatternProvider, + submit, + onIndexPatternSelected, confirmWipeWorkspace, + onIndexPatternChange, } = props; - const [query, setQuery] = useState({ language: 'kuery', query: initialQuery || '' }); - const [currentIndexPattern, setCurrentIndexPattern] = useState( - undefined - ); + const [query, setQuery] = useState({ language: 'kuery', query: urlQuery || '' }); + + useEffect(() => setQuery((prev) => ({ language: prev.language, query: urlQuery || '' })), [ + urlQuery, + ]); useEffect(() => { async function fetchPattern() { if (currentDatasource) { - setCurrentIndexPattern(await indexPatternProvider.get(currentDatasource.id)); + onIndexPatternChange(await indexPatternProvider.get(currentDatasource.id)); } else { - setCurrentIndexPattern(undefined); + onIndexPatternChange(undefined); } } fetchPattern(); - }, [currentDatasource, indexPatternProvider]); + }, [currentDatasource, indexPatternProvider, onIndexPatternChange]); const kibana = useKibana(); const { services, overlays } = kibana; @@ -101,7 +106,7 @@ export function SearchBarComponent(props: SearchBarProps) { onSubmit={(e) => { e.preventDefault(); if (!isLoading && currentIndexPattern) { - onQuerySubmit(queryToString(query, currentIndexPattern)); + submit(queryToString(query, currentIndexPattern)); } }} > @@ -196,5 +201,8 @@ export const SearchBar = connect( }) ); }, + submit: (searchTerm: string) => { + dispatch(submitSearch(searchTerm)); + }, }) )(SearchBarComponent); diff --git a/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx b/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx index 10ee306cd48a2..44ce606b0c1a9 100644 --- a/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx +++ b/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx @@ -8,8 +8,8 @@ import React, { useState, useEffect } from 'react'; import { EuiFormRow, EuiFieldNumber, EuiComboBox, EuiSwitch, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { SettingsProps } from './settings'; import { AdvancedSettings } from '../../types'; +import { SettingsStateProps } from './settings'; // Helper type to get all keys of an interface // that are of type number. @@ -26,9 +26,10 @@ export function AdvancedSettingsForm({ advancedSettings, updateSettings, allFields, -}: Pick) { +}: Pick) { // keep a local state during changes const [formState, updateFormState] = useState({ ...advancedSettings }); + // useEffect update localState only based on the main store useEffect(() => { updateFormState(advancedSettings); diff --git a/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx b/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx index 6f6b759f1ee1b..8954e812bdb88 100644 --- a/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx +++ b/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx @@ -17,14 +17,15 @@ import { EuiCallOut, } from '@elastic/eui'; -import { SettingsProps } from './settings'; +import { SettingsWorkspaceProps } from './settings'; import { LegacyIcon } from '../legacy_icon'; import { useListKeys } from './use_list_keys'; export function BlocklistForm({ blocklistedNodes, - unblocklistNode, -}: Pick) { + unblockNode, + unblockAll, +}: Pick) { const getListKey = useListKeys(blocklistedNodes || []); return ( <> @@ -46,7 +47,7 @@ export function BlocklistForm({ /> )} - {blocklistedNodes && unblocklistNode && blocklistedNodes.length > 0 && ( + {blocklistedNodes && blocklistedNodes.length > 0 && ( <> {blocklistedNodes.map((node) => ( @@ -63,9 +64,7 @@ export function BlocklistForm({ defaultMessage: 'Delete', }), color: 'danger', - onClick: () => { - unblocklistNode(node); - }, + onClick: () => unblockNode(node), }} /> ))} @@ -77,11 +76,7 @@ export function BlocklistForm({ iconType="trash" size="s" fill - onClick={() => { - blocklistedNodes.forEach((node) => { - unblocklistNode(node); - }); - }} + onClick={() => unblockAll()} > {i18n.translate('xpack.graph.settings.blocklist.clearButtonLabel', { defaultMessage: 'Delete all', diff --git a/x-pack/plugins/graph/public/components/settings/settings.test.tsx b/x-pack/plugins/graph/public/components/settings/settings.test.tsx index f0d506cf47556..060b1e93fbdc0 100644 --- a/x-pack/plugins/graph/public/components/settings/settings.test.tsx +++ b/x-pack/plugins/graph/public/components/settings/settings.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiTab, EuiListGroupItem, EuiButton, EuiAccordion, EuiFieldText } from '@elastic/eui'; import * as Rx from 'rxjs'; import { mountWithIntl } from '@kbn/test/jest'; -import { Settings, AngularProps } from './settings'; +import { Settings, SettingsWorkspaceProps } from './settings'; import { act } from '@testing-library/react'; import { ReactWrapper } from 'enzyme'; import { UrlTemplateForm } from './url_template_form'; @@ -46,7 +46,7 @@ describe('settings', () => { isDefault: false, }; - const angularProps: jest.Mocked = { + const workspaceProps: jest.Mocked = { blocklistedNodes: [ { x: 0, @@ -83,11 +83,12 @@ describe('settings', () => { }, }, ], - unblocklistNode: jest.fn(), + unblockNode: jest.fn(), + unblockAll: jest.fn(), canEditDrillDownUrls: true, }; - let subject: Rx.BehaviorSubject>; + let subject: Rx.BehaviorSubject>; let instance: ReactWrapper; beforeEach(() => { @@ -137,7 +138,7 @@ describe('settings', () => { ); dispatchSpy = jest.fn(store.dispatch); store.dispatch = dispatchSpy; - subject = new Rx.BehaviorSubject(angularProps); + subject = new Rx.BehaviorSubject(workspaceProps); instance = mountWithIntl( @@ -217,7 +218,7 @@ describe('settings', () => { it('should update on new data', () => { act(() => { subject.next({ - ...angularProps, + ...workspaceProps, blocklistedNodes: [ { x: 0, @@ -250,14 +251,13 @@ describe('settings', () => { it('should delete node', () => { instance.find(EuiListGroupItem).at(0).prop('extraAction')!.onClick!({} as any); - expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![0]); + expect(workspaceProps.unblockNode).toHaveBeenCalledWith(workspaceProps.blocklistedNodes![0]); }); it('should delete all nodes', () => { instance.find('[data-test-subj="graphUnblocklistAll"]').find(EuiButton).simulate('click'); - expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![0]); - expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![1]); + expect(workspaceProps.unblockAll).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/graph/public/components/settings/settings.tsx b/x-pack/plugins/graph/public/components/settings/settings.tsx index ab9cfdfe38072..d8f18add4f375 100644 --- a/x-pack/plugins/graph/public/components/settings/settings.tsx +++ b/x-pack/plugins/graph/public/components/settings/settings.tsx @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiFlyoutHeader, EuiTitle, EuiTabs, EuiFlyoutBody, EuiTab } from '@elastic/eui'; import * as Rx from 'rxjs'; import { connect } from 'react-redux'; @@ -14,7 +14,7 @@ import { bindActionCreators } from 'redux'; import { AdvancedSettingsForm } from './advanced_settings_form'; import { BlocklistForm } from './blocklist_form'; import { UrlTemplateList } from './url_template_list'; -import { WorkspaceNode, AdvancedSettings, UrlTemplate, WorkspaceField } from '../../types'; +import { AdvancedSettings, BlockListedNode, UrlTemplate, WorkspaceField } from '../../types'; import { GraphState, settingsSelector, @@ -47,16 +47,6 @@ const tabs = [ }, ]; -/** - * These props are wired in the angular scope and are passed in via observable - * to catch update outside updates - */ -export interface AngularProps { - blocklistedNodes: WorkspaceNode[]; - unblocklistNode: (node: WorkspaceNode) => void; - canEditDrillDownUrls: boolean; -} - export interface StateProps { advancedSettings: AdvancedSettings; urlTemplates: UrlTemplate[]; @@ -69,26 +59,43 @@ export interface DispatchProps { saveTemplate: (props: { index: number; template: UrlTemplate }) => void; } -interface AsObservable

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

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

+ + + +
+ {!isInitialized && ( +
+ +
+ )} + + {isInitialized && workspace && ( +
+
+ +
+ + +
+ )} + + ); +}; + +export const WorkspaceLayout = connect( + (state: GraphState) => ({ + workspaceInitialized: workspaceInitializedSelector(state), + hasFields: hasFieldsSelector(state), + }) +)(WorkspaceLayoutComponent); diff --git a/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx b/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx new file mode 100644 index 0000000000000..c5b10b9d92120 --- /dev/null +++ b/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { Provider, useStore } from 'react-redux'; +import { AppMountParameters, Capabilities, CoreStart } from 'kibana/public'; +import { useHistory, useLocation } from 'react-router-dom'; +import { NavigationPublicPluginStart as NavigationStart } from '../../../../../../src/plugins/navigation/public'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { datasourceSelector, hasFieldsSelector } from '../../state_management'; +import { GraphSavePolicy, GraphWorkspaceSavedObject, Workspace } from '../../types'; +import { AsObservable, Settings, SettingsWorkspaceProps } from '../settings'; +import { asSyncedObservable } from '../../helpers/as_observable'; + +interface WorkspaceTopNavMenuProps { + workspace: Workspace | undefined; + setShowInspect: React.Dispatch>; + confirmWipeWorkspace: ( + onConfirm: () => void, + text?: string, + options?: { confirmButtonText: string; title: string } + ) => void; + savedWorkspace: GraphWorkspaceSavedObject; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + graphSavePolicy: GraphSavePolicy; + navigation: NavigationStart; + capabilities: Capabilities; + coreStart: CoreStart; + canEditDrillDownUrls: boolean; + isInitialized: boolean; +} + +export const WorkspaceTopNavMenu = (props: WorkspaceTopNavMenuProps) => { + const store = useStore(); + const location = useLocation(); + const history = useHistory(); + + // register things for legacy angular UI + const allSavingDisabled = props.graphSavePolicy === 'none'; + + // ===== Menubar configuration ========= + const { TopNavMenu } = props.navigation.ui; + const topNavMenu = []; + topNavMenu.push({ + key: 'new', + label: i18n.translate('xpack.graph.topNavMenu.newWorkspaceLabel', { + defaultMessage: 'New', + }), + description: i18n.translate('xpack.graph.topNavMenu.newWorkspaceAriaLabel', { + defaultMessage: 'New Workspace', + }), + tooltip: i18n.translate('xpack.graph.topNavMenu.newWorkspaceTooltip', { + defaultMessage: 'Create a new workspace', + }), + disableButton() { + return !props.isInitialized; + }, + run() { + props.confirmWipeWorkspace(() => { + if (location.pathname === '/workspace/') { + history.go(0); + } else { + history.push('/workspace/'); + } + }); + }, + testId: 'graphNewButton', + }); + + // if saving is disabled using uiCapabilities, we don't want to render the save + // button so it's consistent with all of the other applications + if (props.capabilities.graph.save) { + // allSavingDisabled is based on the xpack.graph.savePolicy, we'll maintain this functionality + + topNavMenu.push({ + key: 'save', + label: i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledLabel', { + defaultMessage: 'Save', + }), + description: i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledAriaLabel', { + defaultMessage: 'Save workspace', + }), + tooltip: () => { + if (allSavingDisabled) { + return i18n.translate('xpack.graph.topNavMenu.saveWorkspace.disabledTooltip', { + defaultMessage: + 'No changes to saved workspaces are permitted by the current save policy', + }); + } else { + return i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledTooltip', { + defaultMessage: 'Save this workspace', + }); + } + }, + disableButton() { + return allSavingDisabled || !hasFieldsSelector(store.getState()); + }, + run: () => { + store.dispatch({ type: 'x-pack/graph/SAVE_WORKSPACE', payload: props.savedWorkspace }); + }, + testId: 'graphSaveButton', + }); + } + topNavMenu.push({ + key: 'inspect', + disableButton() { + return props.workspace === null; + }, + label: i18n.translate('xpack.graph.topNavMenu.inspectLabel', { + defaultMessage: 'Inspect', + }), + description: i18n.translate('xpack.graph.topNavMenu.inspectAriaLabel', { + defaultMessage: 'Inspect', + }), + run: () => { + props.setShowInspect((prevShowInspect) => !prevShowInspect); + }, + }); + + topNavMenu.push({ + key: 'settings', + disableButton() { + return datasourceSelector(store.getState()).current.type === 'none'; + }, + label: i18n.translate('xpack.graph.topNavMenu.settingsLabel', { + defaultMessage: 'Settings', + }), + description: i18n.translate('xpack.graph.topNavMenu.settingsAriaLabel', { + defaultMessage: 'Settings', + }), + run: () => { + // At this point workspace should be initialized, + // since settings button will be disabled only if workspace was set + const workspace = props.workspace as Workspace; + + const settingsObservable = (asSyncedObservable(() => ({ + blocklistedNodes: workspace.blocklistedNodes, + unblockNode: workspace.unblockNode, + unblockAll: workspace.unblockAll, + canEditDrillDownUrls: props.canEditDrillDownUrls, + })) as unknown) as AsObservable['observable']; + + props.coreStart.overlays.openFlyout( + toMountPoint( + + + + ), + { + size: 'm', + closeButtonAriaLabel: i18n.translate('xpack.graph.settings.closeLabel', { + defaultMessage: 'Close', + }), + 'data-test-subj': 'graphSettingsFlyout', + ownFocus: true, + className: 'gphSettingsFlyout', + maxWidth: 520, + } + ); + }, + }); + + return ( + + ); +}; diff --git a/x-pack/plugins/graph/public/helpers/as_observable.ts b/x-pack/plugins/graph/public/helpers/as_observable.ts index c1fa963641366..146161cceb46d 100644 --- a/x-pack/plugins/graph/public/helpers/as_observable.ts +++ b/x-pack/plugins/graph/public/helpers/as_observable.ts @@ -12,19 +12,20 @@ interface Props { } /** - * This is a helper to tie state updates that happen somewhere else back to an angular scope. + * This is a helper to tie state updates that happen somewhere else back to an react state. * It is roughly comparable to `reactDirective`, but does not have to be used from within a * template. * - * This is a temporary solution until the state management is moved outside of Angular. + * This is a temporary solution until the state of Workspace internals is moved outside + * of mutable object to the redux state (at least blocklistedNodes, canEditDrillDownUrls and + * unblocklist action in this case). * * @param collectProps Function that collects properties from the scope that should be passed - * into the observable. All functions passed along will be wrapped to cause an angular digest cycle - * and refresh the observable afterwards with a new call to `collectProps`. By doing so, angular - * can react to changes made outside of it and the results are passed back via the observable - * @param angularDigest The `$digest` function of the scope. + * into the observable. All functions passed along will be wrapped to cause a react render + * and refresh the observable afterwards with a new call to `collectProps`. By doing so, react + * will receive an update outside of it local state and the results are passed back via the observable. */ -export function asAngularSyncedObservable(collectProps: () => Props, angularDigest: () => void) { +export function asSyncedObservable(collectProps: () => Props) { const boundCollectProps = () => { const collectedProps = collectProps(); Object.keys(collectedProps).forEach((key) => { @@ -32,7 +33,6 @@ export function asAngularSyncedObservable(collectProps: () => Props, angularDige if (typeof currentValue === 'function') { collectedProps[key] = (...args: unknown[]) => { currentValue(...args); - angularDigest(); subject$.next(boundCollectProps()); }; } diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts index 1d8be0fe86b97..336708173d321 100644 --- a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts +++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts @@ -49,7 +49,7 @@ const defaultsProps = { const urlFor = (basePath: IBasePath, id: string) => basePath.prepend(`/app/graph#/workspace/${encodeURIComponent(id)}`); -function mapHits(hit: { id: string; attributes: Record }, url: string) { +function mapHits(hit: any, url: string): GraphWorkspaceSavedObject { const source = hit.attributes; source.id = hit.id; source.url = url; diff --git a/x-pack/plugins/graph/public/helpers/use_graph_loader.ts b/x-pack/plugins/graph/public/helpers/use_graph_loader.ts new file mode 100644 index 0000000000000..c133f6bf260cd --- /dev/null +++ b/x-pack/plugins/graph/public/helpers/use_graph_loader.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useState } from 'react'; +import { ToastsStart } from 'kibana/public'; +import { IHttpFetchError, CoreStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { ExploreRequest, GraphExploreCallback, GraphSearchCallback, SearchRequest } from '../types'; +import { formatHttpError } from './format_http_error'; + +interface UseGraphLoaderProps { + toastNotifications: ToastsStart; + coreStart: CoreStart; +} + +export const useGraphLoader = ({ toastNotifications, coreStart }: UseGraphLoaderProps) => { + const [loading, setLoading] = useState(false); + + const handleHttpError = useCallback( + (error: IHttpFetchError) => { + toastNotifications.addDanger(formatHttpError(error)); + }, + [toastNotifications] + ); + + const handleSearchQueryError = useCallback( + (err: Error | string) => { + const toastTitle = i18n.translate('xpack.graph.errorToastTitle', { + defaultMessage: 'Graph Error', + description: '"Graph" is a product name and should not be translated.', + }); + if (err instanceof Error) { + toastNotifications.addError(err, { + title: toastTitle, + }); + } else { + toastNotifications.addDanger({ + title: toastTitle, + text: String(err), + }); + } + }, + [toastNotifications] + ); + + // Replacement function for graphClientWorkspace's comms so + // that it works with Kibana. + const callNodeProxy = useCallback( + (indexName: string, query: ExploreRequest, responseHandler: GraphExploreCallback) => { + const request = { + body: JSON.stringify({ + index: indexName, + query, + }), + }; + setLoading(true); + return coreStart.http + .post('../api/graph/graphExplore', request) + .then(function (data) { + const response = data.resp; + if (response.timed_out) { + toastNotifications.addWarning( + i18n.translate('xpack.graph.exploreGraph.timedOutWarningText', { + defaultMessage: 'Exploration timed out', + }) + ); + } + responseHandler(response); + }) + .catch(handleHttpError) + .finally(() => setLoading(false)); + }, + [coreStart.http, handleHttpError, toastNotifications] + ); + + // Helper function for the graphClientWorkspace to perform a query + const callSearchNodeProxy = useCallback( + (indexName: string, query: SearchRequest, responseHandler: GraphSearchCallback) => { + const request = { + body: JSON.stringify({ + index: indexName, + body: query, + }), + }; + setLoading(true); + coreStart.http + .post('../api/graph/searchProxy', request) + .then(function (data) { + const response = data.resp; + responseHandler(response); + }) + .catch(handleHttpError) + .finally(() => setLoading(false)); + }, + [coreStart.http, handleHttpError] + ); + + return { + loading, + callNodeProxy, + callSearchNodeProxy, + handleSearchQueryError, + }; +}; diff --git a/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts b/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts new file mode 100644 index 0000000000000..8b91546d52446 --- /dev/null +++ b/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract, ToastsStart } from 'kibana/public'; +import { useEffect, useState } from 'react'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { GraphStore } from '../state_management'; +import { GraphWorkspaceSavedObject, IndexPatternSavedObject, Workspace } from '../types'; +import { getSavedWorkspace } from './saved_workspace_utils'; + +interface UseWorkspaceLoaderProps { + store: GraphStore; + workspaceRef: React.MutableRefObject; + savedObjectsClient: SavedObjectsClientContract; + toastNotifications: ToastsStart; +} + +interface WorkspaceUrlParams { + id?: string; +} + +export const useWorkspaceLoader = ({ + workspaceRef, + store, + savedObjectsClient, + toastNotifications, +}: UseWorkspaceLoaderProps) => { + const [indexPatterns, setIndexPatterns] = useState(); + const [savedWorkspace, setSavedWorkspace] = useState(); + const history = useHistory(); + const location = useLocation(); + const { id } = useParams(); + + /** + * The following effect initializes workspace initially and reacts + * on changes in id parameter and URL query only. + */ + useEffect(() => { + const urlQuery = new URLSearchParams(location.search).get('query'); + + function loadWorkspace( + fetchedSavedWorkspace: GraphWorkspaceSavedObject, + fetchedIndexPatterns: IndexPatternSavedObject[] + ) { + store.dispatch({ + type: 'x-pack/graph/LOAD_WORKSPACE', + payload: { + savedWorkspace: fetchedSavedWorkspace, + indexPatterns: fetchedIndexPatterns, + urlQuery, + }, + }); + } + + function clearStore() { + store.dispatch({ type: 'x-pack/graph/RESET' }); + } + + async function fetchIndexPatterns() { + return await savedObjectsClient + .find<{ title: string }>({ + type: 'index-pattern', + fields: ['title', 'type'], + perPage: 10000, + }) + .then((response) => response.savedObjects); + } + + async function fetchSavedWorkspace() { + return (id + ? await getSavedWorkspace(savedObjectsClient, id).catch(function (e) { + toastNotifications.addError(e, { + title: i18n.translate('xpack.graph.missingWorkspaceErrorMessage', { + defaultMessage: "Couldn't load graph with ID", + }), + }); + history.replace('/home'); + // return promise that never returns to prevent the controller from loading + return new Promise(() => {}); + }) + : await getSavedWorkspace(savedObjectsClient)) as GraphWorkspaceSavedObject; + } + + async function initializeWorkspace() { + const fetchedIndexPatterns = await fetchIndexPatterns(); + const fetchedSavedWorkspace = await fetchSavedWorkspace(); + + /** + * Deal with situation of request to open saved workspace. Otherwise clean up store, + * when navigating to a new workspace from existing one. + */ + if (fetchedSavedWorkspace.id) { + loadWorkspace(fetchedSavedWorkspace, fetchedIndexPatterns); + } else if (workspaceRef.current) { + clearStore(); + } + + setIndexPatterns(fetchedIndexPatterns); + setSavedWorkspace(fetchedSavedWorkspace); + } + + initializeWorkspace(); + }, [ + id, + location, + store, + history, + savedObjectsClient, + setSavedWorkspace, + toastNotifications, + workspaceRef, + ]); + + return { savedWorkspace, indexPatterns }; +}; diff --git a/x-pack/plugins/graph/public/index.scss b/x-pack/plugins/graph/public/index.scss index f4e38de3e93a4..4062864dd41e0 100644 --- a/x-pack/plugins/graph/public/index.scss +++ b/x-pack/plugins/graph/public/index.scss @@ -10,5 +10,4 @@ @import './mixins'; @import './main'; -@import './angular/templates/index'; @import './components/index'; diff --git a/x-pack/plugins/graph/public/plugin.ts b/x-pack/plugins/graph/public/plugin.ts index 70671260ce5b9..1ff9afe505a3b 100644 --- a/x-pack/plugins/graph/public/plugin.ts +++ b/x-pack/plugins/graph/public/plugin.ts @@ -84,7 +84,6 @@ export class GraphPlugin updater$: this.appUpdater$, mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart] = await core.getStartServices(); - await pluginsStart.kibanaLegacy.loadAngularBootstrap(); coreStart.chrome.docTitle.change( i18n.translate('xpack.graph.pageTitle', { defaultMessage: 'Graph' }) ); @@ -104,7 +103,7 @@ export class GraphPlugin canEditDrillDownUrls: config.canEditDrillDownUrls, graphSavePolicy: config.savePolicy, storage: new Storage(window.localStorage), - capabilities: coreStart.application.capabilities.graph, + capabilities: coreStart.application.capabilities, chrome: coreStart.chrome, toastNotifications: coreStart.notifications.toasts, indexPatterns: pluginsStart.data!.indexPatterns, diff --git a/x-pack/plugins/graph/public/router.tsx b/x-pack/plugins/graph/public/router.tsx new file mode 100644 index 0000000000000..61a39bbbf63dd --- /dev/null +++ b/x-pack/plugins/graph/public/router.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { createHashHistory } from 'history'; +import { Redirect, Route, Router, Switch } from 'react-router-dom'; +import { ListingRoute } from './apps/listing_route'; +import { GraphServices } from './application'; +import { WorkspaceRoute } from './apps/workspace_route'; + +export const graphRouter = (deps: GraphServices) => { + const history = createHashHistory(); + + return ( + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts b/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts index 443d8581c435d..31826c3b3a747 100644 --- a/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts +++ b/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts @@ -7,7 +7,7 @@ import { GraphWorkspaceSavedObject, IndexPatternSavedObject, Workspace } from '../../types'; import { migrateLegacyIndexPatternRef, savedWorkspaceToAppState, mapFields } from './deserialize'; -import { createWorkspace } from '../../angular/graph_client_workspace'; +import { createWorkspace } from '../../services/workspace/graph_client_workspace'; import { outlinkEncoders } from '../../helpers/outlink_encoders'; import { IndexPattern } from '../../../../../../src/plugins/data/public'; diff --git a/x-pack/plugins/graph/public/services/persistence/serialize.test.ts b/x-pack/plugins/graph/public/services/persistence/serialize.test.ts index 8213aac3fd62e..2466582bc7b25 100644 --- a/x-pack/plugins/graph/public/services/persistence/serialize.test.ts +++ b/x-pack/plugins/graph/public/services/persistence/serialize.test.ts @@ -146,7 +146,7 @@ describe('serialize', () => { target: appState.workspace.nodes[0], weight: 5, width: 5, - }); + } as WorkspaceEdge); // C <-> E appState.workspace.edges.push({ @@ -155,7 +155,7 @@ describe('serialize', () => { target: appState.workspace.nodes[4], weight: 5, width: 5, - }); + } as WorkspaceEdge); }); it('should serialize given workspace', () => { diff --git a/x-pack/plugins/graph/public/services/persistence/serialize.ts b/x-pack/plugins/graph/public/services/persistence/serialize.ts index 65392b69b5a6e..e1ec8db19a4c4 100644 --- a/x-pack/plugins/graph/public/services/persistence/serialize.ts +++ b/x-pack/plugins/graph/public/services/persistence/serialize.ts @@ -6,7 +6,6 @@ */ import { - SerializedNode, WorkspaceNode, WorkspaceEdge, SerializedEdge, @@ -17,13 +16,15 @@ import { SerializedWorkspaceState, Workspace, AdvancedSettings, + SerializedNode, + BlockListedNode, } from '../../types'; import { IndexpatternDatasource } from '../../state_management'; function serializeNode( - { data, scaledSize, parent, x, y, label, color }: WorkspaceNode, + { data, scaledSize, parent, x, y, label, color }: BlockListedNode, allNodes: WorkspaceNode[] = [] -): SerializedNode { +) { return { x, y, diff --git a/x-pack/plugins/graph/public/services/save_modal.tsx b/x-pack/plugins/graph/public/services/save_modal.tsx index eff98ebeded47..f1603ed790d3a 100644 --- a/x-pack/plugins/graph/public/services/save_modal.tsx +++ b/x-pack/plugins/graph/public/services/save_modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { ReactElement } from 'react'; import { I18nStart, OverlayStart, SavedObjectsClientContract } from 'src/core/public'; import { SaveResult } from 'src/plugins/saved_objects/public'; import { GraphWorkspaceSavedObject, GraphSavePolicy } from '../types'; @@ -39,7 +39,7 @@ export function openSaveModal({ hasData: boolean; workspace: GraphWorkspaceSavedObject; saveWorkspace: SaveWorkspaceHandler; - showSaveModal: (el: React.ReactNode, I18nContext: I18nStart['Context']) => void; + showSaveModal: (el: ReactElement, I18nContext: I18nStart['Context']) => void; I18nContext: I18nStart['Context']; services: SaveWorkspaceServices; }) { diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.d.ts b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.d.ts similarity index 100% rename from x-pack/plugins/graph/public/angular/graph_client_workspace.d.ts rename to x-pack/plugins/graph/public/services/workspace/graph_client_workspace.d.ts diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.js b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js similarity index 99% rename from x-pack/plugins/graph/public/angular/graph_client_workspace.js rename to x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js index 07e4dfc2e874a..c849a25cb19bb 100644 --- a/x-pack/plugins/graph/public/angular/graph_client_workspace.js +++ b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js @@ -631,10 +631,14 @@ function GraphWorkspace(options) { self.runLayout(); }; - this.unblocklist = function (node) { + this.unblockNode = function (node) { self.arrRemove(self.blocklistedNodes, node); }; + this.unblockAll = function () { + self.arrRemoveAll(self.blocklistedNodes, self.blocklistedNodes); + }; + this.blocklistSelection = function () { const selection = self.getAllSelectedNodes(); const danglingEdges = []; diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.test.js similarity index 100% rename from x-pack/plugins/graph/public/angular/graph_client_workspace.test.js rename to x-pack/plugins/graph/public/services/workspace/graph_client_workspace.test.js diff --git a/x-pack/plugins/graph/public/state_management/advanced_settings.ts b/x-pack/plugins/graph/public/state_management/advanced_settings.ts index 82f1358dd4164..68b9e002766e3 100644 --- a/x-pack/plugins/graph/public/state_management/advanced_settings.ts +++ b/x-pack/plugins/graph/public/state_management/advanced_settings.ts @@ -43,14 +43,14 @@ export const settingsSelector = (state: GraphState) => state.advancedSettings; * * Won't be necessary once the workspace is moved to redux */ -export const syncSettingsSaga = ({ getWorkspace, notifyAngular }: GraphStoreDependencies) => { +export const syncSettingsSaga = ({ getWorkspace, notifyReact }: GraphStoreDependencies) => { function* syncSettings(action: Action): IterableIterator { const workspace = getWorkspace(); if (!workspace) { return; } workspace.options.exploreControls = action.payload; - notifyAngular(); + notifyReact(); } return function* () { diff --git a/x-pack/plugins/graph/public/state_management/datasource.sagas.ts b/x-pack/plugins/graph/public/state_management/datasource.sagas.ts index b185af28c3481..9bfc7b3da0f91 100644 --- a/x-pack/plugins/graph/public/state_management/datasource.sagas.ts +++ b/x-pack/plugins/graph/public/state_management/datasource.sagas.ts @@ -30,7 +30,7 @@ export const datasourceSaga = ({ indexPatternProvider, notifications, createWorkspace, - notifyAngular, + notifyReact, }: GraphStoreDependencies) => { function* fetchFields(action: Action) { try { @@ -39,7 +39,7 @@ export const datasourceSaga = ({ yield put(datasourceLoaded()); const advancedSettings = settingsSelector(yield select()); createWorkspace(indexPattern.title, advancedSettings); - notifyAngular(); + notifyReact(); } catch (e) { // in case of errors, reset the datasource and show notification yield put(setDatasource({ type: 'none' })); diff --git a/x-pack/plugins/graph/public/state_management/fields.ts b/x-pack/plugins/graph/public/state_management/fields.ts index 051f5328091e1..3a117fa6fe50a 100644 --- a/x-pack/plugins/graph/public/state_management/fields.ts +++ b/x-pack/plugins/graph/public/state_management/fields.ts @@ -69,9 +69,9 @@ export const hasFieldsSelector = createSelector( * * Won't be necessary once the workspace is moved to redux */ -export const updateSaveButtonSaga = ({ notifyAngular }: GraphStoreDependencies) => { +export const updateSaveButtonSaga = ({ notifyReact }: GraphStoreDependencies) => { function* notify(): IterableIterator { - notifyAngular(); + notifyReact(); } return function* () { yield takeLatest(matchesOne(selectField, deselectField), notify); @@ -84,7 +84,7 @@ export const updateSaveButtonSaga = ({ notifyAngular }: GraphStoreDependencies) * * Won't be necessary once the workspace is moved to redux */ -export const syncFieldsSaga = ({ getWorkspace, setLiveResponseFields }: GraphStoreDependencies) => { +export const syncFieldsSaga = ({ getWorkspace }: GraphStoreDependencies) => { function* syncFields() { const workspace = getWorkspace(); if (!workspace) { @@ -93,7 +93,6 @@ export const syncFieldsSaga = ({ getWorkspace, setLiveResponseFields }: GraphSto const currentState = yield select(); workspace.options.vertex_fields = selectedFieldsSelector(currentState); - setLiveResponseFields(liveResponseFieldsSelector(currentState)); } return function* () { yield takeEvery( @@ -109,7 +108,7 @@ export const syncFieldsSaga = ({ getWorkspace, setLiveResponseFields }: GraphSto * * Won't be necessary once the workspace is moved to redux */ -export const syncNodeStyleSaga = ({ getWorkspace, notifyAngular }: GraphStoreDependencies) => { +export const syncNodeStyleSaga = ({ getWorkspace, notifyReact }: GraphStoreDependencies) => { function* syncNodeStyle(action: Action>) { const workspace = getWorkspace(); if (!workspace) { @@ -132,7 +131,7 @@ export const syncNodeStyleSaga = ({ getWorkspace, notifyAngular }: GraphStoreDep } }); } - notifyAngular(); + notifyReact(); const selectedFields = selectedFieldsSelector(yield select()); workspace.options.vertex_fields = selectedFields; diff --git a/x-pack/plugins/graph/public/state_management/legacy.test.ts b/x-pack/plugins/graph/public/state_management/legacy.test.ts index 1dbad39a918a5..5a05efdc478fc 100644 --- a/x-pack/plugins/graph/public/state_management/legacy.test.ts +++ b/x-pack/plugins/graph/public/state_management/legacy.test.ts @@ -77,13 +77,12 @@ describe('legacy sync sagas', () => { it('syncs templates with workspace', () => { env.store.dispatch(loadTemplates([])); - expect(env.mockedDeps.setUrlTemplates).toHaveBeenCalledWith([]); - expect(env.mockedDeps.notifyAngular).toHaveBeenCalled(); + expect(env.mockedDeps.notifyReact).toHaveBeenCalled(); }); it('notifies angular when fields are selected', () => { env.store.dispatch(selectField('field1')); - expect(env.mockedDeps.notifyAngular).toHaveBeenCalled(); + expect(env.mockedDeps.notifyReact).toHaveBeenCalled(); }); it('syncs field list with workspace', () => { @@ -99,9 +98,6 @@ describe('legacy sync sagas', () => { const workspace = env.mockedDeps.getWorkspace()!; expect(workspace.options.vertex_fields![0].name).toEqual('field1'); expect(workspace.options.vertex_fields![0].hopSize).toEqual(22); - expect(env.mockedDeps.setLiveResponseFields).toHaveBeenCalledWith([ - expect.objectContaining({ hopSize: 22 }), - ]); }); it('syncs styles with nodes', () => { diff --git a/x-pack/plugins/graph/public/state_management/mocks.ts b/x-pack/plugins/graph/public/state_management/mocks.ts index 74d980753a09a..189875d04b015 100644 --- a/x-pack/plugins/graph/public/state_management/mocks.ts +++ b/x-pack/plugins/graph/public/state_management/mocks.ts @@ -15,7 +15,7 @@ import createSagaMiddleware from 'redux-saga'; import { createStore, applyMiddleware, AnyAction } from 'redux'; import { ChromeStart } from 'kibana/public'; import { GraphStoreDependencies, createRootReducer, GraphStore, GraphState } from './store'; -import { Workspace, GraphWorkspaceSavedObject, IndexPatternSavedObject } from '../types'; +import { Workspace } from '../types'; import { IndexPattern } from '../../../../../src/plugins/data/public'; export interface MockedGraphEnvironment { @@ -48,11 +48,8 @@ export function createMockGraphStore({ blocklistedNodes: [], } as unknown) as Workspace; - const savedWorkspace = ({ - save: jest.fn(), - } as unknown) as GraphWorkspaceSavedObject; - const mockedDeps: jest.Mocked = { + basePath: '', addBasePath: jest.fn((url: string) => url), changeUrl: jest.fn(), chrome: ({ @@ -60,15 +57,11 @@ export function createMockGraphStore({ } as unknown) as ChromeStart, createWorkspace: jest.fn(), getWorkspace: jest.fn(() => workspaceMock), - getSavedWorkspace: jest.fn(() => savedWorkspace), indexPatternProvider: { get: jest.fn(() => Promise.resolve(({ id: '123', title: 'test-pattern' } as unknown) as IndexPattern) ), }, - indexPatterns: [ - ({ id: '123', attributes: { title: 'test-pattern' } } as unknown) as IndexPatternSavedObject, - ], I18nContext: jest .fn() .mockImplementation(({ children }: { children: React.ReactNode }) => children), @@ -79,12 +72,9 @@ export function createMockGraphStore({ }, } as unknown) as NotificationsStart, http: {} as HttpStart, - notifyAngular: jest.fn(), + notifyReact: jest.fn(), savePolicy: 'configAndData', showSaveModal: jest.fn(), - setLiveResponseFields: jest.fn(), - setUrlTemplates: jest.fn(), - setWorkspaceInitialized: jest.fn(), overlays: ({ openModal: jest.fn(), } as unknown) as OverlayStart, @@ -92,6 +82,7 @@ export function createMockGraphStore({ find: jest.fn(), get: jest.fn(), } as unknown) as SavedObjectsClientContract, + handleSearchQueryError: jest.fn(), ...mockedDepsOverwrites, }; const sagaMiddleware = createSagaMiddleware(); diff --git a/x-pack/plugins/graph/public/state_management/persistence.test.ts b/x-pack/plugins/graph/public/state_management/persistence.test.ts index b0932c92c2d1e..dc59869fafd4c 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.test.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.test.ts @@ -6,8 +6,14 @@ */ import { createMockGraphStore, MockedGraphEnvironment } from './mocks'; -import { loadSavedWorkspace, loadingSaga, saveWorkspace, savingSaga } from './persistence'; -import { GraphWorkspaceSavedObject, UrlTemplate, AdvancedSettings, WorkspaceField } from '../types'; +import { + loadSavedWorkspace, + loadingSaga, + saveWorkspace, + savingSaga, + LoadSavedWorkspacePayload, +} from './persistence'; +import { UrlTemplate, AdvancedSettings, WorkspaceField, GraphWorkspaceSavedObject } from '../types'; import { IndexpatternDatasource, datasourceSelector } from './datasource'; import { fieldsSelector } from './fields'; import { metaDataSelector, updateMetaData } from './meta_data'; @@ -55,7 +61,9 @@ describe('persistence sagas', () => { }); it('should deserialize saved object and populate state', async () => { env.store.dispatch( - loadSavedWorkspace({ title: 'my workspace' } as GraphWorkspaceSavedObject) + loadSavedWorkspace({ + savedWorkspace: { title: 'my workspace' }, + } as LoadSavedWorkspacePayload) ); await waitForPromise(); const resultingState = env.store.getState(); @@ -70,7 +78,7 @@ describe('persistence sagas', () => { it('should warn with a toast and abort if index pattern is not found', async () => { (migrateLegacyIndexPatternRef as jest.Mock).mockReturnValueOnce({ success: false }); - env.store.dispatch(loadSavedWorkspace({} as GraphWorkspaceSavedObject)); + env.store.dispatch(loadSavedWorkspace({ savedWorkspace: {} } as LoadSavedWorkspacePayload)); await waitForPromise(); expect(env.mockedDeps.notifications.toasts.addDanger).toHaveBeenCalled(); const resultingState = env.store.getState(); @@ -96,11 +104,10 @@ describe('persistence sagas', () => { savePolicy: 'configAndDataWithConsent', }, }); - env.mockedDeps.getSavedWorkspace().id = '123'; }); it('should serialize saved object and save after confirmation', async () => { - env.store.dispatch(saveWorkspace()); + env.store.dispatch(saveWorkspace({ id: '123' } as GraphWorkspaceSavedObject)); (openSaveModal as jest.Mock).mock.calls[0][0].saveWorkspace({}, true); expect(appStateToSavedWorkspace).toHaveBeenCalled(); await waitForPromise(); @@ -112,7 +119,7 @@ describe('persistence sagas', () => { }); it('should not save data if user does not give consent in the modal', async () => { - env.store.dispatch(saveWorkspace()); + env.store.dispatch(saveWorkspace({} as GraphWorkspaceSavedObject)); (openSaveModal as jest.Mock).mock.calls[0][0].saveWorkspace({}, false); // serialize function is called with `canSaveData` set to false expect(appStateToSavedWorkspace).toHaveBeenCalledWith( @@ -123,9 +130,8 @@ describe('persistence sagas', () => { }); it('should not change url if it was just updating existing workspace', async () => { - env.mockedDeps.getSavedWorkspace().id = '123'; env.store.dispatch(updateMetaData({ savedObjectId: '123' })); - env.store.dispatch(saveWorkspace()); + env.store.dispatch(saveWorkspace({} as GraphWorkspaceSavedObject)); await waitForPromise(); expect(env.mockedDeps.changeUrl).not.toHaveBeenCalled(); }); diff --git a/x-pack/plugins/graph/public/state_management/persistence.ts b/x-pack/plugins/graph/public/state_management/persistence.ts index f815474fa6e51..6a99eaddb32e3 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.ts @@ -8,8 +8,8 @@ import actionCreatorFactory, { Action } from 'typescript-fsa'; import { i18n } from '@kbn/i18n'; import { takeLatest, call, put, select, cps } from 'redux-saga/effects'; -import { GraphWorkspaceSavedObject, Workspace } from '../types'; -import { GraphStoreDependencies, GraphState } from '.'; +import { GraphWorkspaceSavedObject, IndexPatternSavedObject, Workspace } from '../types'; +import { GraphStoreDependencies, GraphState, submitSearch } from '.'; import { datasourceSelector } from './datasource'; import { setDatasource, IndexpatternDatasource } from './datasource'; import { loadFields, selectedFieldsSelector } from './fields'; @@ -26,10 +26,17 @@ import { openSaveModal, SaveWorkspaceHandler } from '../services/save_modal'; import { getEditPath } from '../services/url'; import { saveSavedWorkspace } from '../helpers/saved_workspace_utils'; +export interface LoadSavedWorkspacePayload { + indexPatterns: IndexPatternSavedObject[]; + savedWorkspace: GraphWorkspaceSavedObject; + urlQuery: string | null; +} + const actionCreator = actionCreatorFactory('x-pack/graph'); -export const loadSavedWorkspace = actionCreator('LOAD_WORKSPACE'); -export const saveWorkspace = actionCreator('SAVE_WORKSPACE'); +export const loadSavedWorkspace = actionCreator('LOAD_WORKSPACE'); +export const saveWorkspace = actionCreator('SAVE_WORKSPACE'); +export const fillWorkspace = actionCreator('FILL_WORKSPACE'); /** * Saga handling loading of a saved workspace. @@ -39,14 +46,12 @@ export const saveWorkspace = actionCreator('SAVE_WORKSPACE'); */ export const loadingSaga = ({ createWorkspace, - getWorkspace, - indexPatterns, notifications, indexPatternProvider, }: GraphStoreDependencies) => { - function* deserializeWorkspace(action: Action) { - const workspacePayload = action.payload; - const migrationStatus = migrateLegacyIndexPatternRef(workspacePayload, indexPatterns); + function* deserializeWorkspace(action: Action) { + const { indexPatterns, savedWorkspace, urlQuery } = action.payload; + const migrationStatus = migrateLegacyIndexPatternRef(savedWorkspace, indexPatterns); if (!migrationStatus.success) { notifications.toasts.addDanger( i18n.translate('xpack.graph.loadWorkspace.missingIndexPatternErrorMessage', { @@ -59,25 +64,24 @@ export const loadingSaga = ({ return; } - const selectedIndexPatternId = lookupIndexPatternId(workspacePayload); + const selectedIndexPatternId = lookupIndexPatternId(savedWorkspace); const indexPattern = yield call(indexPatternProvider.get, selectedIndexPatternId); const initialSettings = settingsSelector(yield select()); - createWorkspace(indexPattern.title, initialSettings); + const createdWorkspace = createWorkspace(indexPattern.title, initialSettings); const { urlTemplates, advancedSettings, allFields } = savedWorkspaceToAppState( - workspacePayload, + savedWorkspace, indexPattern, - // workspace won't be null because it's created in the same call stack - getWorkspace()! + createdWorkspace ); // put everything in the store yield put( updateMetaData({ - title: workspacePayload.title, - description: workspacePayload.description, - savedObjectId: workspacePayload.id, + title: savedWorkspace.title, + description: savedWorkspace.description, + savedObjectId: savedWorkspace.id, }) ); yield put( @@ -91,7 +95,11 @@ export const loadingSaga = ({ yield put(updateSettings(advancedSettings)); yield put(loadTemplates(urlTemplates)); - getWorkspace()!.runLayout(); + if (urlQuery) { + yield put(submitSearch(urlQuery)); + } + + createdWorkspace.runLayout(); } return function* () { @@ -105,8 +113,8 @@ export const loadingSaga = ({ * It will serialize everything and save it using the saved objects client */ export const savingSaga = (deps: GraphStoreDependencies) => { - function* persistWorkspace() { - const savedWorkspace = deps.getSavedWorkspace(); + function* persistWorkspace(action: Action) { + const savedWorkspace = action.payload; const state: GraphState = yield select(); const workspace = deps.getWorkspace(); const selectedDatasource = datasourceSelector(state).current; diff --git a/x-pack/plugins/graph/public/state_management/store.ts b/x-pack/plugins/graph/public/state_management/store.ts index 400736f7534b6..ba9bff98b0ca9 100644 --- a/x-pack/plugins/graph/public/state_management/store.ts +++ b/x-pack/plugins/graph/public/state_management/store.ts @@ -9,6 +9,7 @@ import createSagaMiddleware, { SagaMiddleware } from 'redux-saga'; import { combineReducers, createStore, Store, AnyAction, Dispatch, applyMiddleware } from 'redux'; import { ChromeStart, I18nStart, OverlayStart, SavedObjectsClientContract } from 'kibana/public'; import { CoreStart } from 'src/core/public'; +import { ReactElement } from 'react'; import { fieldsReducer, FieldsState, @@ -24,19 +25,10 @@ import { } from './advanced_settings'; import { DatasourceState, datasourceReducer } from './datasource'; import { datasourceSaga } from './datasource.sagas'; -import { - IndexPatternProvider, - Workspace, - IndexPatternSavedObject, - GraphSavePolicy, - GraphWorkspaceSavedObject, - AdvancedSettings, - WorkspaceField, - UrlTemplate, -} from '../types'; +import { IndexPatternProvider, Workspace, GraphSavePolicy, AdvancedSettings } from '../types'; import { loadingSaga, savingSaga } from './persistence'; import { metaDataReducer, MetaDataState, syncBreadcrumbSaga } from './meta_data'; -import { fillWorkspaceSaga } from './workspace'; +import { fillWorkspaceSaga, submitSearchSaga, workspaceReducer, WorkspaceState } from './workspace'; export interface GraphState { fields: FieldsState; @@ -44,28 +36,26 @@ export interface GraphState { advancedSettings: AdvancedSettingsState; datasource: DatasourceState; metaData: MetaDataState; + workspace: WorkspaceState; } export interface GraphStoreDependencies { addBasePath: (url: string) => string; indexPatternProvider: IndexPatternProvider; - indexPatterns: IndexPatternSavedObject[]; - createWorkspace: (index: string, advancedSettings: AdvancedSettings) => void; - getWorkspace: () => Workspace | null; - getSavedWorkspace: () => GraphWorkspaceSavedObject; + createWorkspace: (index: string, advancedSettings: AdvancedSettings) => Workspace; + getWorkspace: () => Workspace | undefined; notifications: CoreStart['notifications']; http: CoreStart['http']; overlays: OverlayStart; savedObjectsClient: SavedObjectsClientContract; - showSaveModal: (el: React.ReactNode, I18nContext: I18nStart['Context']) => void; + showSaveModal: (el: ReactElement, I18nContext: I18nStart['Context']) => void; savePolicy: GraphSavePolicy; changeUrl: (newUrl: string) => void; - notifyAngular: () => void; - setLiveResponseFields: (fields: WorkspaceField[]) => void; - setUrlTemplates: (templates: UrlTemplate[]) => void; - setWorkspaceInitialized: () => void; + notifyReact: () => void; chrome: ChromeStart; I18nContext: I18nStart['Context']; + basePath: string; + handleSearchQueryError: (err: Error | string) => void; } export function createRootReducer(addBasePath: (url: string) => string) { @@ -75,6 +65,7 @@ export function createRootReducer(addBasePath: (url: string) => string) { advancedSettings: advancedSettingsReducer, datasource: datasourceReducer, metaData: metaDataReducer, + workspace: workspaceReducer, }); } @@ -89,6 +80,7 @@ function registerSagas(sagaMiddleware: SagaMiddleware, deps: GraphStoreD sagaMiddleware.run(syncBreadcrumbSaga(deps)); sagaMiddleware.run(syncTemplatesSaga(deps)); sagaMiddleware.run(fillWorkspaceSaga(deps)); + sagaMiddleware.run(submitSearchSaga(deps)); } export const createGraphStore = (deps: GraphStoreDependencies) => { diff --git a/x-pack/plugins/graph/public/state_management/url_templates.ts b/x-pack/plugins/graph/public/state_management/url_templates.ts index e8f5308534e28..01b1a9296b0b6 100644 --- a/x-pack/plugins/graph/public/state_management/url_templates.ts +++ b/x-pack/plugins/graph/public/state_management/url_templates.ts @@ -10,7 +10,7 @@ import { reducerWithInitialState } from 'typescript-fsa-reducers/dist'; import { i18n } from '@kbn/i18n'; import { modifyUrl } from '@kbn/std'; import rison from 'rison-node'; -import { takeEvery, select } from 'redux-saga/effects'; +import { takeEvery } from 'redux-saga/effects'; import { format, parse } from 'url'; import { GraphState, GraphStoreDependencies } from './store'; import { UrlTemplate } from '../types'; @@ -102,11 +102,9 @@ export const templatesSelector = (state: GraphState) => state.urlTemplates; * * Won't be necessary once the side bar is moved to redux */ -export const syncTemplatesSaga = ({ setUrlTemplates, notifyAngular }: GraphStoreDependencies) => { +export const syncTemplatesSaga = ({ notifyReact }: GraphStoreDependencies) => { function* syncTemplates() { - const templates = templatesSelector(yield select()); - setUrlTemplates(templates); - notifyAngular(); + notifyReact(); } return function* () { diff --git a/x-pack/plugins/graph/public/state_management/workspace.ts b/x-pack/plugins/graph/public/state_management/workspace.ts index 4e0e481a05c17..9e8cca488e4ef 100644 --- a/x-pack/plugins/graph/public/state_management/workspace.ts +++ b/x-pack/plugins/graph/public/state_management/workspace.ts @@ -5,16 +5,41 @@ * 2.0. */ -import actionCreatorFactory from 'typescript-fsa'; +import actionCreatorFactory, { Action } from 'typescript-fsa'; import { i18n } from '@kbn/i18n'; -import { takeLatest, select, call } from 'redux-saga/effects'; -import { GraphStoreDependencies, GraphState } from '.'; +import { takeLatest, select, call, put } from 'redux-saga/effects'; +import { reducerWithInitialState } from 'typescript-fsa-reducers'; +import { createSelector } from 'reselect'; +import { GraphStoreDependencies, GraphState, fillWorkspace } from '.'; +import { reset } from './global'; import { datasourceSelector } from './datasource'; -import { selectedFieldsSelector } from './fields'; +import { liveResponseFieldsSelector, selectedFieldsSelector } from './fields'; import { fetchTopNodes } from '../services/fetch_top_nodes'; -const actionCreator = actionCreatorFactory('x-pack/graph'); +import { Workspace } from '../types'; -export const fillWorkspace = actionCreator('FILL_WORKSPACE'); +const actionCreator = actionCreatorFactory('x-pack/graph/workspace'); + +export interface WorkspaceState { + isInitialized: boolean; +} + +const initialWorkspaceState: WorkspaceState = { + isInitialized: false, +}; + +export const initializeWorkspace = actionCreator('INITIALIZE_WORKSPACE'); +export const submitSearch = actionCreator('SUBMIT_SEARCH'); + +export const workspaceReducer = reducerWithInitialState(initialWorkspaceState) + .case(reset, () => ({ isInitialized: false })) + .case(initializeWorkspace, () => ({ isInitialized: true })) + .build(); + +export const workspaceSelector = (state: GraphState) => state.workspace; +export const workspaceInitializedSelector = createSelector( + workspaceSelector, + (workspace: WorkspaceState) => workspace.isInitialized +); /** * Saga handling filling in top terms into workspace. @@ -23,8 +48,7 @@ export const fillWorkspace = actionCreator('FILL_WORKSPACE'); */ export const fillWorkspaceSaga = ({ getWorkspace, - setWorkspaceInitialized, - notifyAngular, + notifyReact, http, notifications, }: GraphStoreDependencies) => { @@ -47,8 +71,8 @@ export const fillWorkspaceSaga = ({ nodes: topTermNodes, edges: [], }); - setWorkspaceInitialized(); - notifyAngular(); + yield put(initializeWorkspace()); + notifyReact(); workspace.fillInGraph(fields.length * 10); } catch (e) { const message = 'body' in e ? e.body.message : e.message; @@ -65,3 +89,39 @@ export const fillWorkspaceSaga = ({ yield takeLatest(fillWorkspace.match, fetchNodes); }; }; + +export const submitSearchSaga = ({ + getWorkspace, + handleSearchQueryError, +}: GraphStoreDependencies) => { + function* submit(action: Action) { + const searchTerm = action.payload; + yield put(initializeWorkspace()); + + // type casting is safe, at this point workspace should be loaded + const workspace = getWorkspace() as Workspace; + const numHops = 2; + const liveResponseFields = liveResponseFieldsSelector(yield select()); + + if (searchTerm.startsWith('{')) { + try { + const query = JSON.parse(searchTerm); + if (query.vertices) { + // Is a graph explore request + workspace.callElasticsearch(query); + } else { + // Is a regular query DSL query + workspace.search(query, liveResponseFields, numHops); + } + } catch (err) { + handleSearchQueryError(err); + } + return; + } + workspace.simpleSearch(searchTerm, liveResponseFields, numHops); + } + + return function* () { + yield takeLatest(submitSearch.match, submit); + }; +}; diff --git a/x-pack/plugins/graph/public/types/persistence.ts b/x-pack/plugins/graph/public/types/persistence.ts index 46d711de04205..640348d96f6ac 100644 --- a/x-pack/plugins/graph/public/types/persistence.ts +++ b/x-pack/plugins/graph/public/types/persistence.ts @@ -53,15 +53,15 @@ export interface SerializedField extends Omit { +export interface SerializedNode extends Pick { field: string; term: string; parent: number | null; size: number; } -export interface SerializedEdge extends Omit { +export interface SerializedEdge + extends Omit { source: number; target: number; } diff --git a/x-pack/plugins/graph/public/types/workspace_state.ts b/x-pack/plugins/graph/public/types/workspace_state.ts index 86f05376b9526..bca94a7cfad6d 100644 --- a/x-pack/plugins/graph/public/types/workspace_state.ts +++ b/x-pack/plugins/graph/public/types/workspace_state.ts @@ -6,10 +6,13 @@ */ import { JsonObject } from '@kbn/utility-types'; +import d3 from 'd3'; +import { TargetOptions } from '../components/control_panel'; import { FontawesomeIcon } from '../helpers/style_choices'; import { WorkspaceField, AdvancedSettings } from './app_state'; export interface WorkspaceNode { + id: string; x: number; y: number; label: string; @@ -21,9 +24,14 @@ export interface WorkspaceNode { scaledSize: number; parent: WorkspaceNode | null; color: string; + numChildren: number; isSelected?: boolean; + kx: number; + ky: number; } +export type BlockListedNode = Omit; + export interface WorkspaceEdge { weight: number; width: number; @@ -31,6 +39,8 @@ export interface WorkspaceEdge { source: WorkspaceNode; target: WorkspaceNode; isSelected?: boolean; + topTarget: WorkspaceNode; + topSrc: WorkspaceNode; } export interface ServerResultNode { @@ -58,13 +68,59 @@ export interface GraphData { nodes: ServerResultNode[]; edges: ServerResultEdge[]; } +export interface TermIntersect { + id1: string; + id2: string; + term1: string; + term2: string; + v1: number; + v2: number; + overlap: number; +} export interface Workspace { options: WorkspaceOptions; nodesMap: Record; nodes: WorkspaceNode[]; + selectedNodes: WorkspaceNode[]; edges: WorkspaceEdge[]; - blocklistedNodes: WorkspaceNode[]; + blocklistedNodes: BlockListedNode[]; + undoLog: string; + redoLog: string; + force: ReturnType; + lastRequest: string; + lastResponse: string; + + undo: () => void; + redo: () => void; + expandSelecteds: (targetOptions: TargetOptions) => {}; + deleteSelection: () => void; + blocklistSelection: () => void; + selectAll: () => void; + selectNone: () => void; + selectInvert: () => void; + selectNeighbours: () => void; + deselectNode: (node: WorkspaceNode) => void; + colorSelected: (color: string) => void; + groupSelections: (node: WorkspaceNode | undefined) => void; + ungroup: (node: WorkspaceNode | undefined) => void; + callElasticsearch: (request: any) => void; + search: (qeury: any, fieldsChoice: WorkspaceField[] | undefined, numHops: number) => void; + simpleSearch: ( + searchTerm: string, + fieldsChoice: WorkspaceField[] | undefined, + numHops: number + ) => void; + getAllIntersections: ( + callback: (termIntersects: TermIntersect[]) => void, + nodes: WorkspaceNode[] + ) => void; + toggleNodeSelection: (node: WorkspaceNode) => boolean; + mergeIds: (term1: string, term2: string) => void; + changeHandler: () => void; + unblockNode: (node: BlockListedNode) => void; + unblockAll: () => void; + clearGraph: () => void; getQuery(startNodes?: WorkspaceNode[], loose?: boolean): JsonObject; getSelectedOrAllNodes(): WorkspaceNode[]; @@ -96,6 +152,8 @@ export type ExploreRequest = any; export type SearchRequest = any; export type ExploreResults = any; export type SearchResults = any; +export type GraphExploreCallback = (data: ExploreResults) => void; +export type GraphSearchCallback = (data: SearchResults) => void; export type WorkspaceOptions = Partial<{ indexName: string; @@ -105,12 +163,14 @@ export type WorkspaceOptions = Partial<{ graphExploreProxy: ( indexPattern: string, request: ExploreRequest, - callback: (data: ExploreResults) => void + callback: GraphExploreCallback ) => void; searchProxy: ( indexPattern: string, request: SearchRequest, - callback: (data: SearchResults) => void + callback: GraphSearchCallback ) => void; exploreControls: AdvancedSettings; }>; + +export type ControlType = 'style' | 'drillDowns' | 'editLabel' | 'mergeTerms' | 'none'; From 95eab7ccca0c66c0291ee248c1f900e5970d8be8 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 31 Aug 2021 19:54:13 +0200 Subject: [PATCH 11/18] [ML] Fix "Show charts" control state (#110602) * [ML] fix show charts state * [ML] fix export --- .../checkbox_showcharts.tsx | 34 ++++++------------- .../controls/checkbox_showcharts/index.ts | 2 +- .../public/application/explorer/explorer.js | 5 ++- .../explorer/explorer_constants.ts | 1 + .../explorer/explorer_dashboard_service.ts | 7 ++++ .../reducers/explorer_reducer/reducer.ts | 7 ++++ .../reducers/explorer_reducer/state.ts | 2 ++ .../application/routing/routes/explorer.tsx | 14 +++++--- 8 files changed, 43 insertions(+), 29 deletions(-) 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, From a3fd138da1aefcd2c66465f348ec6af0acd8f2c7 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Tue, 31 Aug 2021 21:06:47 +0300 Subject: [PATCH 12/18] do not make an assumption on user-supplied data content (#109425) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/core/server/elasticsearch/client/client_config.ts | 3 +++ 1 file changed, 3 insertions(+) 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) { From f8c80a74222b9a57e52cf3b7d46d74311f00508e Mon Sep 17 00:00:00 2001 From: Gloria Hornero Date: Tue, 31 Aug 2021 20:07:06 +0200 Subject: [PATCH 13/18] [Security Solution] Updates loock-back time on Cypress tests (#110609) * updates loock-back time * updates loock-back value for 'expectedExportedRule' --- x-pack/plugins/security_solution/cypress/objects/rule.ts | 4 ++-- .../security_solution/cypress/tasks/api_calls/rules.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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/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', From bbfad1905124ba21d5dcfcddacab042cd66183cc Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 31 Aug 2021 11:07:56 -0700 Subject: [PATCH 14/18] [Reporting] Remove `any` from public/poller (#110539) * [Reporting] Remove `any` from public/poller * remove unnecessary comment --- x-pack/plugins/reporting/common/poller.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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; From 42acf39a703027cde4f0d74d8651a063d0a348e2 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Tue, 31 Aug 2021 13:08:17 -0500 Subject: [PATCH 15/18] [ML] Populate date fields for Transform (#108804) * [ML] Add index pattern info & select control for date time * [ML] Update translations * [ML] Gracefully handle when index pattern is not available * [ML] Fix import * [ML] Handle when unmounted * [ML] Remove load index patterns because we don't really need it * [ML] Add error obj to error toasts * [ML] Update tests * [ML] Update hook Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../action_clone/use_clone_action.tsx | 12 +- .../action_edit/use_edit_action.tsx | 56 ++++++++-- .../edit_transform_flyout.tsx | 13 ++- .../edit_transform_flyout_form.tsx | 103 +++++++++++++++--- .../components/transform_list/use_actions.tsx | 6 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../permissions/full_transform_access.ts | 5 +- .../services/transform/edit_flyout.ts | 15 +++ 9 files changed, 172 insertions(+), 40 deletions(-) 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 a41e0695a1bd7..770f24114d8ef 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23566,7 +23566,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 8d2e3607d1d32..c28fdbed5f31c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -24117,7 +24117,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/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/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`); From ca120eef9166be93398876f5c8af988478b13670 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 31 Aug 2021 11:08:49 -0700 Subject: [PATCH 16/18] [Reporting] Remove `any` from pdf job compatibility shim (#110555) * [Reporting] Remove `any` from pdf job compatibility shim * remove `any` usage in a few other isolated areas --- .../plugins/reporting/public/management/report_listing.tsx | 4 ++-- .../public/notifier/job_completion_notifications.ts | 4 ++-- .../plugins/reporting/server/browsers/safe_child_process.ts | 2 +- .../printable_pdf/create_job/compatibility_shim.ts | 6 +++--- x-pack/plugins/reporting/server/types.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) 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 From 1ea921368fa1bd2c18965227164c831cf024c4d4 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 31 Aug 2021 11:09:07 -0700 Subject: [PATCH 17/18] [Reporting/Docs] Clarify reporting user access control options (#110545) * [Reporting/Docs] Clarify reporting user access control with kibana privileges * add reporting docs to code owners * Update docs/setup/configuring-reporting.asciidoc Co-authored-by: Kaarina Tungseth * Update docs/settings/reporting-settings.asciidoc Co-authored-by: Kaarina Tungseth * Update docs/setup/configuring-reporting.asciidoc Co-authored-by: Kaarina Tungseth * Update docs/setup/configuring-reporting.asciidoc Co-authored-by: Kaarina Tungseth Co-authored-by: Kaarina Tungseth --- .github/CODEOWNERS | 3 +++ docs/settings/reporting-settings.asciidoc | 13 ++++++------- docs/setup/configuring-reporting.asciidoc | 17 ++++++++++++----- 3 files changed, 21 insertions(+), 12 deletions(-) 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/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] --------------------------------------------------------------- From 23a178895f7fe2d762cd5587359fe4099118233b Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 31 Aug 2021 13:17:50 -0500 Subject: [PATCH 18/18] [renovate] cleanup and disable dependency dashboard (#110664) --- renovate.json5 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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, }, {