From 52fcd66aa6f93573cadb43d8b67f0062ec2487bd Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Wed, 9 May 2018 17:21:43 +0200 Subject: [PATCH] Add Inspector feature --- .../plugin/development-uiexports.asciidoc | 1 + package.json | 4 +- src/core_plugins/dev_mode/index.js | 18 --- src/core_plugins/dev_mode/package.json | 4 - .../dev_mode/public/vis_debug_spy_panel.html | 23 --- .../dev_mode/public/vis_debug_spy_panel.js | 14 -- src/core_plugins/kibana/public/kibana.js | 1 + .../kibana/public/visualize/editor/editor.js | 14 ++ .../embeddable/visualize_embeddable.js | 4 - src/dev/build/tasks/copy_source_task.js | 1 - src/ui/public/agg_types/buckets/terms.js | 17 +- .../courier/utils/courier_inspector_utils.js | 54 +++++++ src/ui/public/inspector/README.md | 127 +++++++++++++++ .../public/inspector/adapters/data_adapter.js | 16 ++ src/ui/public/inspector/adapters/index.js | 2 + .../inspector/adapters/request_adapter.js | 99 ++++++++++++ src/ui/public/inspector/index.js | 12 ++ src/ui/public/inspector/inspector.js | 125 +++++++++++++++ src/ui/public/inspector/inspector.test.js | 90 +++++++++++ src/ui/public/inspector/ui/index.js | 1 + src/ui/public/inspector/ui/inspector.less | 29 ++++ src/ui/public/inspector/ui/inspector_modes.js | 95 +++++++++++ .../public/inspector/ui/inspector_modes2.js | 93 +++++++++++ src/ui/public/inspector/ui/inspector_panel.js | 138 ++++++++++++++++ src/ui/public/inspector/ui/inspector_view.js | 36 +++++ .../public/inspector/views/data/data_table.js | 112 +++++++++++++ .../inspector/views/data/data_table.less | 7 + .../public/inspector/views/data/data_view.js | 132 ++++++++++++++++ .../inspector/views/data/download_options.js | 86 ++++++++++ .../inspector/views/data/lib/export_csv.js | 43 +++++ src/ui/public/inspector/views/index.js | 7 + src/ui/public/inspector/views/registry.js | 78 ++++++++++ .../public/inspector/views/registry.test.js | 76 +++++++++ .../inspector/views/requests/details/index.js | 4 + .../details/req_details_description.js | 21 +++ .../requests/details/req_details_request.js | 19 +++ .../requests/details/req_details_response.js | 19 +++ .../requests/details/req_details_stats.js | 58 +++++++ .../views/requests/request_details.js | 91 +++++++++++ .../views/requests/request_list_entry.js | 59 +++++++ .../views/requests/requests_inspector.less | 15 ++ .../inspector/views/requests/requests_view.js | 132 ++++++++++++++++ src/ui/public/inspector/views/views.js | 2 + src/ui/public/vis/__tests__/_vis.js | 147 ++++++++++++++++++ src/ui/public/vis/request_handlers/courier.js | 68 ++++++++ src/ui/public/vis/vis.js | 55 ++++++- src/ui/public/visualize/loader/loader.js | 5 - .../visualize/loader/loader_template.html | 1 - src/ui/public/visualize/spy.js | 63 ++++++-- src/ui/public/visualize/visualization.html | 7 + src/ui/public/visualize/visualization.js | 4 + src/ui/public/visualize/visualize.js | 4 +- src/ui/ui_exports/ui_export_types/index.js | 1 + .../ui_export_types/ui_app_extensions.js | 1 + tasks/config/run.js | 1 - yarn.lock | 18 +++ 56 files changed, 2264 insertions(+), 90 deletions(-) delete mode 100644 src/core_plugins/dev_mode/index.js delete mode 100644 src/core_plugins/dev_mode/package.json delete mode 100644 src/core_plugins/dev_mode/public/vis_debug_spy_panel.html delete mode 100644 src/core_plugins/dev_mode/public/vis_debug_spy_panel.js create mode 100644 src/ui/public/courier/utils/courier_inspector_utils.js create mode 100644 src/ui/public/inspector/README.md create mode 100644 src/ui/public/inspector/adapters/data_adapter.js create mode 100644 src/ui/public/inspector/adapters/index.js create mode 100644 src/ui/public/inspector/adapters/request_adapter.js create mode 100644 src/ui/public/inspector/index.js create mode 100644 src/ui/public/inspector/inspector.js create mode 100644 src/ui/public/inspector/inspector.test.js create mode 100644 src/ui/public/inspector/ui/index.js create mode 100644 src/ui/public/inspector/ui/inspector.less create mode 100644 src/ui/public/inspector/ui/inspector_modes.js create mode 100644 src/ui/public/inspector/ui/inspector_modes2.js create mode 100644 src/ui/public/inspector/ui/inspector_panel.js create mode 100644 src/ui/public/inspector/ui/inspector_view.js create mode 100644 src/ui/public/inspector/views/data/data_table.js create mode 100644 src/ui/public/inspector/views/data/data_table.less create mode 100644 src/ui/public/inspector/views/data/data_view.js create mode 100644 src/ui/public/inspector/views/data/download_options.js create mode 100644 src/ui/public/inspector/views/data/lib/export_csv.js create mode 100644 src/ui/public/inspector/views/index.js create mode 100644 src/ui/public/inspector/views/registry.js create mode 100644 src/ui/public/inspector/views/registry.test.js create mode 100644 src/ui/public/inspector/views/requests/details/index.js create mode 100644 src/ui/public/inspector/views/requests/details/req_details_description.js create mode 100644 src/ui/public/inspector/views/requests/details/req_details_request.js create mode 100644 src/ui/public/inspector/views/requests/details/req_details_response.js create mode 100644 src/ui/public/inspector/views/requests/details/req_details_stats.js create mode 100644 src/ui/public/inspector/views/requests/request_details.js create mode 100644 src/ui/public/inspector/views/requests/request_list_entry.js create mode 100644 src/ui/public/inspector/views/requests/requests_inspector.less create mode 100644 src/ui/public/inspector/views/requests/requests_view.js create mode 100644 src/ui/public/inspector/views/views.js diff --git a/docs/development/plugin/development-uiexports.asciidoc b/docs/development/plugin/development-uiexports.asciidoc index 3e8e37561cf23..87f25b519bb80 100644 --- a/docs/development/plugin/development-uiexports.asciidoc +++ b/docs/development/plugin/development-uiexports.asciidoc @@ -9,6 +9,7 @@ An aggregate list of available UiExport types: | hacks | Any module that should be included in every application | visTypes | Modules that register providers with the `ui/registry/vis_types` registry. | fieldFormats | Modules that register providers with the `ui/registry/field_formats` registry. +| inspectorViews | Modules that register custom inspector views via the `viewRegistry` in `ui/inspector`. | spyModes | Modules that register providers with the `ui/registry/spy_modes` registry. | chromeNavControls | Modules that register providers with the `ui/registry/chrome_nav_controls` registry. | navbarExtensions | Modules that register providers with the `ui/registry/navbar_extensions` registry. diff --git a/package.json b/package.json index dbb775d5ae9ad..0379f8ee18ca9 100644 --- a/package.json +++ b/package.json @@ -165,11 +165,11 @@ "proxy-from-env": "1.0.0", "querystring-browser": "1.0.4", "raw-loader": "0.5.1", - "react": "^16.2.0", + "react": "^16.3.0", "react-addons-shallow-compare": "15.6.2", "react-anything-sortable": "^1.7.4", "react-color": "^2.13.8", - "react-dom": "^16.2.0", + "react-dom": "^16.3.0", "react-grid-layout": "^0.16.2", "react-input-range": "^1.3.0", "react-markdown": "^3.1.4", diff --git a/src/core_plugins/dev_mode/index.js b/src/core_plugins/dev_mode/index.js deleted file mode 100644 index b44a3c52d204c..0000000000000 --- a/src/core_plugins/dev_mode/index.js +++ /dev/null @@ -1,18 +0,0 @@ -export default (kibana) => { - return new kibana.Plugin({ - id: 'dev_mode', - - isEnabled(config) { - return ( - config.get('env.dev') && - config.get('dev_mode.enabled') - ); - }, - - uiExports: { - spyModes: [ - 'plugins/dev_mode/vis_debug_spy_panel' - ] - } - }); -}; diff --git a/src/core_plugins/dev_mode/package.json b/src/core_plugins/dev_mode/package.json deleted file mode 100644 index 8d1ceddb721a3..0000000000000 --- a/src/core_plugins/dev_mode/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "dev_mode", - "version": "kibana" -} diff --git a/src/core_plugins/dev_mode/public/vis_debug_spy_panel.html b/src/core_plugins/dev_mode/public/vis_debug_spy_panel.html deleted file mode 100644 index 3d7fe64f8ab96..0000000000000 --- a/src/core_plugins/dev_mode/public/vis_debug_spy_panel.html +++ /dev/null @@ -1,23 +0,0 @@ -
-
-
-

- Vis State -

-
- -
{{vis.getEnabledState() | json}}
-
-
-
-
-

Details

-
-
Type Name
-
{{vis.type.name}}
-
Hierarchical Data
-
{{vis.isHierarchical()}}
-
-
-
-
diff --git a/src/core_plugins/dev_mode/public/vis_debug_spy_panel.js b/src/core_plugins/dev_mode/public/vis_debug_spy_panel.js deleted file mode 100644 index ece98bf0b0c7f..0000000000000 --- a/src/core_plugins/dev_mode/public/vis_debug_spy_panel.js +++ /dev/null @@ -1,14 +0,0 @@ -import visDebugSpyPanelTemplate from './vis_debug_spy_panel.html'; -import { SpyModesRegistryProvider } from 'ui/registry/spy_modes'; - -function VisDetailsSpyProvider() { - return { - name: 'debug', - display: 'Debug', - template: visDebugSpyPanelTemplate, - order: 5 - }; -} - -// register the spy mode or it won't show up in the spys -SpyModesRegistryProvider.register(VisDetailsSpyProvider); diff --git a/src/core_plugins/kibana/public/kibana.js b/src/core_plugins/kibana/public/kibana.js index 9f7e61c2d808f..be9eaae6735c2 100644 --- a/src/core_plugins/kibana/public/kibana.js +++ b/src/core_plugins/kibana/public/kibana.js @@ -20,6 +20,7 @@ import 'uiExports/managementSections'; import 'uiExports/devTools'; import 'uiExports/docViews'; import 'uiExports/embeddableFactories'; +import 'uiExports/inspectorViews'; import 'ui/autoload/all'; import './home'; diff --git a/src/core_plugins/kibana/public/visualize/editor/editor.js b/src/core_plugins/kibana/public/visualize/editor/editor.js index 8284a8d4203c8..2182a2a74eb14 100644 --- a/src/core_plugins/kibana/public/visualize/editor/editor.js +++ b/src/core_plugins/kibana/public/visualize/editor/editor.js @@ -111,6 +111,20 @@ function VisEditor($scope, $route, timefilter, AppState, $window, kbnUrl, courie description: 'Share Visualization', template: require('plugins/kibana/visualize/editor/panels/share.html'), testId: 'visualizeShareButton', + }, { + key: 'inspector', + description: 'Open Inspector for visualization', + disableButton() { + return !vis.hasInspector(); + }, + run() { + vis.openInspector().bindToAngularScope($scope); + }, + tooltip() { + if (!vis.hasInspector()) { + return 'This visualization doesn\'t support any inspectors.'; + } + } }, { key: 'refresh', description: 'Refresh', diff --git a/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.js b/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.js index 271aff81f5d3f..53dea0b2b95f1 100644 --- a/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.js +++ b/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.js @@ -1,6 +1,5 @@ import { PersistedState } from 'ui/persisted_state'; import { Embeddable } from 'ui/embeddable'; -import chrome from 'ui/chrome'; import _ from 'lodash'; export class VisualizeEmbeddable extends Embeddable { @@ -43,9 +42,6 @@ export class VisualizeEmbeddable extends Embeddable { append: true, timeRange: this.timeRange, cssClass: `panel-content panel-content--fullWidth`, - // The chrome is permanently hidden in "embed mode" in which case we don't want to show the spy pane, since - // we deem that situation to be more public facing and want to hide more detailed information. - showSpyPanel: !chrome.getIsChromePermanentlyHidden(), dataAttrs: { 'shared-item': '', title: this.panelTitle, diff --git a/src/dev/build/tasks/copy_source_task.js b/src/dev/build/tasks/copy_source_task.js index 0f3b316287667..020ed0ceec552 100644 --- a/src/dev/build/tasks/copy_source_task.js +++ b/src/dev/build/tasks/copy_source_task.js @@ -12,7 +12,6 @@ export const CopySourceTask = { '!src/**/__tests__/**', '!src/test_utils/**', '!src/fixtures/**', - '!src/core_plugins/dev_mode/**', '!src/core_plugins/tests_bundle/**', '!src/core_plugins/testbed/**', '!src/core_plugins/console/public/tests/**', diff --git a/src/ui/public/agg_types/buckets/terms.js b/src/ui/public/agg_types/buckets/terms.js index 3b07662d0e21c..3f8e6d822cf87 100644 --- a/src/ui/public/agg_types/buckets/terms.js +++ b/src/ui/public/agg_types/buckets/terms.js @@ -5,7 +5,9 @@ import { Schemas } from '../../vis/editors/default/schemas'; import { createFilterTerms } from './create_filter/terms'; import orderAggTemplate from '../controls/order_agg.html'; import orderAndSizeTemplate from '../controls/order_and_size.html'; -import otherBucketTemplate from 'ui/agg_types/controls/other_bucket.html'; +import otherBucketTemplate from '../controls/other_bucket.html'; + +import { getRequestInspectorStats, getResponseInspectorStats } from '../../courier/utils/courier_inspector_utils'; import { buildOtherBucketAgg, mergeOtherBucketAggResponse, updateMissingBucket } from './_terms_other_bucket_helper'; import { toastNotifications } from '../../notify'; @@ -59,7 +61,20 @@ export const termsBucketAgg = new BucketAggType({ if (aggConfig.params.otherBucket) { const filterAgg = buildOtherBucketAgg(aggConfigs, aggConfig, resp); nestedSearchSource.set('aggs', filterAgg); + + const request = aggConfigs.vis.API.inspectorAdapters.requests.start('Other Bucket request', { + description: `This request counts how much documents aren't included in the + actual data to build the "Other" bucket from that information.` + }); + nestedSearchSource.getSearchRequestBody().then(body => { + request.json(body); + }); + request.stats(getRequestInspectorStats(nestedSearchSource)); + const response = await nestedSearchSource.fetchAsRejectablePromise(); + request + .stats(getResponseInspectorStats(nestedSearchSource, response)) + .ok({ json: response }); resp = mergeOtherBucketAggResponse(aggConfigs, resp, response, aggConfig, filterAgg()); } if (aggConfig.params.missingBucket) { diff --git a/src/ui/public/courier/utils/courier_inspector_utils.js b/src/ui/public/courier/utils/courier_inspector_utils.js new file mode 100644 index 0000000000000..2db0a5666ddb7 --- /dev/null +++ b/src/ui/public/courier/utils/courier_inspector_utils.js @@ -0,0 +1,54 @@ +/** + * This function collects statistics from a SearchSource and a response + * for the usage in the inspector stats panel. Pass in a searchSource and a response + * and the returned object can be passed to the `stats` method of the request + * logger. + */ +function getRequestInspectorStats(searchSource) { + const stats = {}; + const index = searchSource.get('index'); + + if (index) { + stats['Index Pattern Title'] = { + value: index.title, + description: 'The index pattern against which this query was executed.', + }; + stats ['Index Pattern ID'] = { + value: index.id, + description: 'The ID of the saved index pattern object in the .kibana index.', + }; + } + + return stats; +} + +function getResponseInspectorStats(searchSource, resp) { + const lastRequest = searchSource.history && searchSource.history[searchSource.history.length - 1]; + const stats = {}; + + if (resp && resp.took) { + stats['Query Time'] = { + value: `${resp.took}ms`, + description: `The time it took Elasticsearch to process the query. + This does not include the time it takes to send the request to Elasticsearch + or parse it in the browser.`, + }; + } + + if (resp && resp.hits) { + stats.Hits = { + value: `${resp.hits.total}`, + description: 'The total number of documents that matched the query.', + }; + } + + if (lastRequest && (lastRequest.ms === 0 || lastRequest.ms)) { + stats['Request time'] = { + value: `${lastRequest.ms}ms`, + }; + } + + return stats; +} + +export { getRequestInspectorStats, getResponseInspectorStats }; diff --git a/src/ui/public/inspector/README.md b/src/ui/public/inspector/README.md new file mode 100644 index 0000000000000..61772219f5a50 --- /dev/null +++ b/src/ui/public/inspector/README.md @@ -0,0 +1,127 @@ +# Inspector + +The inspector is a contextual tool to gain insights into different elements +in Kibana, e.g. visualizations. It has the form of a flyout panel. + +## Inspector Views + +The "Inspector Panel" can have multiple so called "Inspector Views" inside of it. +These views are used to gain different information into the element you are inspecting. +There is a request inspector view to gain information in the requests done for this +element or a data inspector view to inspect the underlying data. Whether or not +a specific view is available depends on the used adapters. + +## Inspector Adapters + +Since the Inspector panel itself is not tight to a specific type of elements (visualizations, +saved searches, etc.), everything you need to open the inspector is a collection +of so called inspector adapters. A single adapter can be any type of JavaScript class. + +Most likely an adapter offers some kind of logging capabilities for the element, that +uses it e.g. the request adapter allows element (like visualizations) to log requests +they make. + +The corresponding inspector view will then use the information inside the adapter +to present the data in the panel. That concept allows different types of elements +to use the Inspector panel, while they can use completely or partial different adapters +and inspector views than other elements. + +For example a visualization could provide the request and data adapter while a saved +search could only provide the request adapter and a Vega visualization could additionally +provide a Vega adapter. + +There is no 1 to 1 relationship between adapters and views. An adapter could be used +by multiple views and a view can use data from multiple adapters. It's up to the +view to decide whether or not it wants to be shown for a given adapters list. + +## Develop custom inspectors + +You can extend the inspector panel by adding custom inspector views and inspector +adapters via a plugin. + +### Develop inspector views + +To develop custom inspector views you should first register your file via `uiExports` +in your plugin config: + +```js +export default (kibana) => { + return new kibana.Plugin({ + uiExports: { + inspectorViews: [ 'plugins/your_plugin/custom_view' ], + } + }); +}; +``` + +Within the `custom_view.js` file in your `public` folder, you can define your +inspector view as follows: + +```js +import React from 'react'; +import { InspectorView, viewRegistry } from 'ui/inspector'; + +function MyInspectorComponent(props) { + // props.adapters is the object of all adapters and may vary depending + // on who and where this inspector was opened. You should check for all + // adapters you need, in the below shouldShow method, before accessing + // them here. + return ( + + { /* Always use InspectorView as the wrapping element! */ } + + ); +} + +const MyLittleInspectorView = { + // Title shown to select this view + title: 'Display Name', + // An icon id from the EUI icon list + icon: 'iconName', + // An order to sort the views (lower means first) + order: 10, + // An additional helptext, that wil + help: `And additional help text, that will be shown in the inspector help.`, + shouldShow(adapters) { + // Only show if `someAdapter` is available. Make sure to check for + // all adapters that you want to access in your view later on and + // any additional condition you want to be true to be shown. + return adapters.someAdapter; + }, + // A React component, that will be used for rendering + component: MyInspectorComponent +}; + +viewRegistry.register(MyLittleInspectorView); +``` + +### Develop custom adapters + +An inspector adapter is just a plain JavaScript class, that can e.g. be attached +to custom visualization types, so an inspector view can show additional information for this +visualization. + +To add additional adapters to your visualization type, use the `inspectorAdapters.custom` +object when defining the visualization type: + +```js +class MyCustomInspectorAdapter { + // .... +} + +// inside your visualization type description (usually passed to VisFactory.create...Type) +{ + // ... + inspectorAdapters: { + custom: { + someAdaoter: MyCustomInspectorAdapter + } + } +} +``` + +An instance of MyCustomInspectorAdapter will now be available on each visualization +of that type and can be accessed via `vis.API.inspectorAdapters.someInspector`. + +Custom inspector views can now check for the presence of `adapters.someAdapter` +in their `shouldShow` method and use this adapter in their component. diff --git a/src/ui/public/inspector/adapters/data_adapter.js b/src/ui/public/inspector/adapters/data_adapter.js new file mode 100644 index 0000000000000..bccea028f25f6 --- /dev/null +++ b/src/ui/public/inspector/adapters/data_adapter.js @@ -0,0 +1,16 @@ +import EventEmitter from 'events'; + +class DataAdapter extends EventEmitter { + + setTabularLoader(callback) { + this._tabular = callback; + this.emit('change', 'tabular'); + } + + getTabular() { + return Promise.resolve(this._tabular ? this._tabular() : null); + } + +} + +export { DataAdapter }; diff --git a/src/ui/public/inspector/adapters/index.js b/src/ui/public/inspector/adapters/index.js new file mode 100644 index 0000000000000..4a9fbef237c05 --- /dev/null +++ b/src/ui/public/inspector/adapters/index.js @@ -0,0 +1,2 @@ +export { DataAdapter } from './data_adapter'; +export { RequestAdapter, RequestStatus } from './request_adapter'; diff --git a/src/ui/public/inspector/adapters/request_adapter.js b/src/ui/public/inspector/adapters/request_adapter.js new file mode 100644 index 0000000000000..4d9054f90637d --- /dev/null +++ b/src/ui/public/inspector/adapters/request_adapter.js @@ -0,0 +1,99 @@ +import EventEmitter from 'events'; + +const RequestStatus = { + OK: 'ok', + ERROR: 'error' +}; + +/** + * An API to specify information about a specific request that will be logged. + * Create a new instance to log a request using {@link RequestAdapter#start}. + */ +class RequestResponder { + constructor(request, logger) { + this._request = request; + this._logger = logger; + } + + json(reqJson) { + this._request.json = reqJson; + this._logger._onChange(); + return this; + } + + stats(stats) { + this._request.stats = { + ...(this._request.stats || {}), + ...stats + }; + this._logger._onChange(); + return this; + } + + finish(status, data) { + const time = Date.now() - this._request._startTime; + this._request.time = time; + this._request.response = { + ...data, + status: status, + }; + this._logger._onChange(); + } + + ok(...args) { + this.finish(RequestStatus.OK, ...args); + } + + error(...args) { + this.finish(RequestStatus.ERROR, ...args); + } +} + +/** + * An generic inspector adapter to log requests. + * These can be presented in the inspector using the requests view. + * The adapter is not coupled to a specific implementation or even Elasticsearch + * instead it offers a generic API to log requests of any kind. + * @extends EventEmitter + */ +class RequestAdapter extends EventEmitter { + + _requests = []; + + /** + * Start logging a new request into this request adapter. The new request will + * by default be in a processing state unless you explicitly finish it via + * {@link RequestResponder#finish}, {@link RequestResponder#ok} or + * {@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. + * @return {RequestResponder} An instance to add information to the request and finish it. + */ + start(name, args) { + const req = { + ...args, + name, + }; + req._startTime = Date.now(); + this._requests.push(req); + this._onChange(); + return new RequestResponder(req, this); + } + + reset() { + this._requests = []; + this._onChange(); + } + + getRequests() { + return this._requests; + } + + _onChange() { + this.emit('change'); + } + +} + +export { RequestAdapter, RequestStatus }; diff --git a/src/ui/public/inspector/index.js b/src/ui/public/inspector/index.js new file mode 100644 index 0000000000000..dc8e302ac6d56 --- /dev/null +++ b/src/ui/public/inspector/index.js @@ -0,0 +1,12 @@ +export { + InspectorView, +} from './ui'; + +export { + hasInspector, + openInspector, +} from './inspector'; + +export { + viewRegistry +} from './views'; diff --git a/src/ui/public/inspector/inspector.js b/src/ui/public/inspector/inspector.js new file mode 100644 index 0000000000000..f7ca1dff41c14 --- /dev/null +++ b/src/ui/public/inspector/inspector.js @@ -0,0 +1,125 @@ +import ReactDOM from 'react-dom'; +import React from 'react'; +import EventEmitter from 'events'; + +import { InspectorPanel } from './ui/inspector_panel'; +import { viewRegistry } from './views'; + +let activeSession = null; + +const CONTAINER_ID = 'inspector-container'; + +function getOrCreateContainerElement() { + let container = document.getElementById(CONTAINER_ID); + if (!container) { + container = document.createElement('div'); + container.id = CONTAINER_ID; + document.body.appendChild(container); + } + return container; +} + +/** + * An InspectorSession describes the session of one opened inspector. It offers + * methods to close the inspector again. If you open an inspector you should make + * sure you call {@link InspectorSession#close} when it should be closed. + * Since an inspector could also be closed without calling this method (e.g. because + * the user closes it), you must listen to the "closed" event on this instance. + * It will be emitted whenever the inspector will be closed and you should throw + * away your reference to this instance whenever you receive that event. + * @extends EventEmitter + */ +class InspectorSession extends EventEmitter { + + /** + * Binds the current inspector session to an Angular scope, meaning this inspector + * session will be closed as soon as the Angular scope gets destroyed. + * @param {object} scope - And angular scope object to bind to. + */ + bindToAngularScope(scope) { + const removeWatch = scope.$on('$destroy', () => this.close()); + this.on('closed', () => removeWatch()); + } + + /** + * Closes the opened inspector as long as it's stil the open one. + * If this is not the active session anymore, this method won't do anything. + * If this session was still active and an inspector was closed, the 'closed' + * event will be emitted on this InspectorSession instance. + */ + close() { + if (activeSession === this) { + const container = document.getElementById(CONTAINER_ID); + if (container) { + ReactDOM.unmountComponentAtNode(container); + this.emit('closed'); + } + } + } + +} + +/** + * Checks if a inspector panel could be shown based on the passed adapters. + * + * @param {object} adapters - An object of adapters. This should be the same + * you would pass into `openInspector`. + * @returns {boolean} True, if a call to `openInspector` with the same adapters + * would have shown the inspector panel, false otherwise. + */ +function hasInspector(adapters) { + return viewRegistry.getVisible(adapters).length > 0; +} + +/** + * @typedef {object} InspectorOptions + * @property {string} title - An optional title, that will be shown in the header + * of the inspector. Can be used to give more context about what is being inspected. + */ + +/** + * Opens the inspector panel for the given adapters and close any previously opened + * inspector panel. The previously panel will be closed also if no new panel will be + * opened (e.g. because of the passed adapters no view is available). You can use + * {@link InspectorSession#close} on the return value to close that opened panel again. + * + * @param {object} adapters - An object of adapters for which you want to show + * the inspector panel. + * @param {InspectorOptions} options - Options that configure the inspector. See InspectorOptions type. + * @return {InspectorSession} The session instance for the opened inspector. + */ +function openInspector(adapters, options = {}) { + // If there is an active inspector session close it before opening a new one. + if (activeSession) { + activeSession.close(); + } + + const views = viewRegistry.getVisible(adapters); + + // Don't open inspector if there are no views available for the passed adapters + if (!views || views.length === 0) { + throw new Error(`Tried to open an inspector without views being available. + Make sure to call hasInspector() with the same adapters before to check + if an inspector can be shown.`); + } + + const container = getOrCreateContainerElement(); + const session = activeSession = new InspectorSession(); + + ReactDOM.render( + session.close()} + title={options.title} + />, + container + ); + + return session; +} + +export { + hasInspector, + openInspector, +}; diff --git a/src/ui/public/inspector/inspector.test.js b/src/ui/public/inspector/inspector.test.js new file mode 100644 index 0000000000000..56748adce0dc7 --- /dev/null +++ b/src/ui/public/inspector/inspector.test.js @@ -0,0 +1,90 @@ +import { openInspector, hasInspector } from './inspector'; +jest.mock('./views', () => ({ + viewRegistry: { + getVisible: jest.fn() + } +})); +jest.mock('./ui/inspector_panel', () => ({ + InspectorPanel: () => 'InspectorPanel' +})); +import { viewRegistry } from './views'; + +function setViews(views) { + viewRegistry.getVisible.mockImplementation(() => views); +} + +describe('Inspector', () => { + describe('hasInspector()', () => { + it('should return false if no view would be available', () => { + setViews([]); + expect(hasInspector({})).toBe(false); + }); + + it('should return true if views would be available', () => { + setViews([{}]); + expect(hasInspector({})).toBe(true); + }); + }); + + describe('openInspector()', () => { + it('should throw an error if no views available', () => { + setViews([]); + expect(() => openInspector({})).toThrow(); + }); + + describe('return value', () => { + beforeEach(() => { + setViews([{}]); + }); + + it('should be an object with a close function', () => { + const session = openInspector({}); + expect(typeof session.close).toBe('function'); + }); + + it('should emit the "closed" event if another inspector opens', () => { + const session = openInspector({}); + const spy = jest.fn(); + session.on('closed', spy); + openInspector({}); + expect(spy).toHaveBeenCalled(); + }); + + it('should emit the "closed" event if you call close', () => { + const session = openInspector({}); + const spy = jest.fn(); + session.on('closed', spy); + session.close(); + expect(spy).toHaveBeenCalled(); + }); + + it('can be bound to an angular scope', () => { + const session = openInspector({}); + const spy = jest.fn(); + session.on('closed', spy); + const scope = { + $on: jest.fn(() => () => {}) + }; + session.bindToAngularScope(scope); + expect(scope.$on).toHaveBeenCalled(); + const onCall = scope.$on.mock.calls[0]; + expect(onCall[0]).toBe('$destroy'); + expect(typeof onCall[1]).toBe('function'); + // Call $destroy callback, as angular would when the scope gets destroyed + onCall[1](); + expect(spy).toHaveBeenCalled(); + }); + + it('will remove from angular scope when closed', () => { + const session = openInspector({}); + const unwatchSpy = jest.fn(); + const scope = { + $on: jest.fn(() => unwatchSpy) + }; + session.bindToAngularScope(scope); + session.close(); + expect(unwatchSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/ui/public/inspector/ui/index.js b/src/ui/public/inspector/ui/index.js new file mode 100644 index 0000000000000..d8f1d6ab0ca48 --- /dev/null +++ b/src/ui/public/inspector/ui/index.js @@ -0,0 +1 @@ +export { InspectorView } from './inspector_view'; diff --git a/src/ui/public/inspector/ui/inspector.less b/src/ui/public/inspector/ui/inspector.less new file mode 100644 index 0000000000000..33c492226d45d --- /dev/null +++ b/src/ui/public/inspector/ui/inspector.less @@ -0,0 +1,29 @@ +.inspector-modes__icon { + margin-right: 8px; +} + +.inspector-panel__heading { + border-bottom: 1px solid #D9D9D9; + background-color: #F5F5F5; +} + +.inspector-panel__title { + color: #666; + text-align: center; +} + +.inspector-panel__action { + display: block; +} + +.inspector-panel__action--right { + text-align: right; +} + +.inspector-panel__helpPopover p:last-child { + margin-bottom: 0; +} + +.inspector-view__flex { + display: flex; +} diff --git a/src/ui/public/inspector/ui/inspector_modes.js b/src/ui/public/inspector/ui/inspector_modes.js new file mode 100644 index 0000000000000..4a3a2d42a5229 --- /dev/null +++ b/src/ui/public/inspector/ui/inspector_modes.js @@ -0,0 +1,95 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { + EuiButtonEmpty, + EuiIcon, + EuiKeyPadMenu, + EuiKeyPadMenuItemButton, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; + +class InspectorModes extends Component { + + state = { + isSelectorOpen: false + }; + + toggleSelector = () => { + this.setState((prev) => ({ + isSelectorOpen: !prev.isSelectorOpen + })); + }; + + closeSelector = () => { + this.setState({ + isSelectorOpen: false + }); + }; + + renderMode = (mode, index) => { + return ( + { + this.props.onModeSelected(mode); + this.closeSelector(); + }} + > + + ); + } + + render() { + const { selectedMode, modes } = this.props; + const triggerButton = ( + + { selectedMode.icon && + + ); + + return ( + + Select inspector mode + + { modes.map(this.renderMode) } + + + ); + } +} + +InspectorModes.propTypes = { + modes: PropTypes.array.isRequired, + onModeSelected: PropTypes.func.isRequired, + selectedMode: PropTypes.object.isRequired, +}; + +export { InspectorModes }; diff --git a/src/ui/public/inspector/ui/inspector_modes2.js b/src/ui/public/inspector/ui/inspector_modes2.js new file mode 100644 index 0000000000000..370ce10c2fb88 --- /dev/null +++ b/src/ui/public/inspector/ui/inspector_modes2.js @@ -0,0 +1,93 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiIcon, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; + +class InspectorModes extends Component { + + state = { + isSelectorOpen: false + }; + + toggleSelector = () => { + this.setState((prev) => ({ + isSelectorOpen: !prev.isSelectorOpen + })); + }; + + closeSelector = () => { + this.setState({ + isSelectorOpen: false + }); + }; + + renderMode = (mode, index) => { + return ( + { + this.props.onModeSelected(mode); + this.closeSelector(); + }} + > + {mode.title} + + ); + } + + render() { + const { selectedMode, modes } = this.props; + const triggerButton = ( + + { selectedMode.icon && + + ); + + return ( + + Select mode + + + ); + } +} + +InspectorModes.propTypes = { + modes: PropTypes.array.isRequired, + onModeSelected: PropTypes.func.isRequired, + selectedMode: PropTypes.object.isRequired, +}; + +export { InspectorModes }; diff --git a/src/ui/public/inspector/ui/inspector_panel.js b/src/ui/public/inspector/ui/inspector_panel.js new file mode 100644 index 0000000000000..c0c8299b85a0e --- /dev/null +++ b/src/ui/public/inspector/ui/inspector_panel.js @@ -0,0 +1,138 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiIconTip, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { InspectorModes } from './inspector_modes2'; + +import './inspector.less'; + +class InspectorPanel extends Component { + + constructor(props) { + super(props); + this.state = { + isHelpPopoverOpen: false, + selectedView: props.views[0], + }; + } + + componentWillReceiveProps(props) { + if (props.views !== this.props.views && !props.views.includes(this.state.selectedView)) { + this.setState({ + selectedView: props.views[0], + }); + } + } + + onModeSelected = (view) => { + if (view !== this.state.selectedView) { + this.setState({ + selectedView: view + }); + } + }; + + renderSelectedPanel() { + if (!this.state.selectedView) { + return null; + } + + return ( + + ); + } + + renderHelpButton() { + const helpText = ( + +

+ Using the Inspector you can gain insights into your visualization. +

+ { this.state.selectedView.help && +

{ this.state.selectedView.help }

+ } +
+ ); + return ( + + ); + } + + render() { + const { views, onClose, title } = this.props; + const { selectedView } = this.state; + + return ( + + + + + +

{ title }

+
+
+ + { /* TODO: rename to views */ } + + +
+
+ { this.renderSelectedPanel() } + + + Close + + +
+ ); + } +} + +InspectorPanel.defaultProps = { + title: 'Inspector', +}; + +InspectorPanel.propTypes = { + adapters: PropTypes.object.isRequired, + views: PropTypes.array.isRequired, + onClose: PropTypes.func.isRequired, + title: PropTypes.string, +}; + +export { InspectorPanel }; diff --git a/src/ui/public/inspector/ui/inspector_view.js b/src/ui/public/inspector/ui/inspector_view.js new file mode 100644 index 0000000000000..4183f95c3f9f2 --- /dev/null +++ b/src/ui/public/inspector/ui/inspector_view.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { + EuiFlyoutBody, +} from '@elastic/eui'; + +/** + * The InspectorView component should be the top most element in every implemented + * inspector view. It makes sure, that the appropriate stylings are applied to the + * view. + */ +function InspectorView(props) { + const classes = classNames({ + 'inspector-view__flex': Boolean(props.useFlex) + }); + return ( + + {props.children} + + ); +} + +InspectorView.propTypes = { + /** + * Any children that you want to render in the view. + */ + children: PropTypes.node.isRequired, + /** + * Set to true if the element should have display: flex set. + */ + useFlex: PropTypes.bool, +}; + +export { InspectorView }; diff --git a/src/ui/public/inspector/views/data/data_table.js b/src/ui/public/inspector/views/data/data_table.js new file mode 100644 index 0000000000000..2ddb61feed370 --- /dev/null +++ b/src/ui/public/inspector/views/data/data_table.js @@ -0,0 +1,112 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import './data_table.less'; + +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiToolTip, +} from '@elastic/eui'; + +import { DataDownloadOptions } from './download_options'; + +class DataTableFormat extends Component { + + state = { }; + + static renderCell(col, value) { + return ( + + + { value } + + { col.filter && + + + col.filter(value)} + /> + + + } + { col.filterOut && + + + col.filterOut(value)} + /> + + + } + + ); + } + + static getDerivedStateFromProps({ data }) { + if (!data) { + return { + columns: null, + rowsRaw: null, + rows: null, + }; + } + + const columns = data.columns.map(col => ({ + name: col.name, + field: col.field, + sortable: true, + render: (value) => DataTableFormat.renderCell(col, value), + })); + + return { columns, rowsRaw: data.rowsRaw, rows: data.rows }; + } + + render() { + const { columns, rows } = this.state; + const search = { + toolsRight: [ + + ] + }; + return ( + + ); + } +} + +DataTableFormat.propTypes = { + data: PropTypes.object.isRequired, + exportTitle: PropTypes.string.isRequired, +}; + +export { DataTableFormat }; diff --git a/src/ui/public/inspector/views/data/data_table.less b/src/ui/public/inspector/views/data/data_table.less new file mode 100644 index 0000000000000..cb4ef6b08846c --- /dev/null +++ b/src/ui/public/inspector/views/data/data_table.less @@ -0,0 +1,7 @@ +.inspector-table__filter { + opacity: 0; +} + +tr:hover .inspector-table__filter { + opacity: 1; +} diff --git a/src/ui/public/inspector/views/data/data_view.js b/src/ui/public/inspector/views/data/data_view.js new file mode 100644 index 0000000000000..966d2705205ce --- /dev/null +++ b/src/ui/public/inspector/views/data/data_view.js @@ -0,0 +1,132 @@ +import React, { Component } from 'react'; + +import { + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingChart, +} from '@elastic/eui'; + +import { InspectorView } from '../..'; + +import { + DataTableFormat, +} from './data_table'; + +class DataViewComponent extends Component { + + _isMounted = false; + state = { + tabularData: null, + tabularLoader: null, + } + + static getDerivedStateFromProps(nextProps) { + return { + tabularData: null, + tabularPromise: nextProps.adapters.data.getTabular(), + }; + } + + onUpdateData = (type) => { + if (type === 'tabular') { + this.setState({ + tabularData: null, + tabularPromise: this.props.adapters.data.getTabular(), + }); + } + }; + + finishLoadingData() { + if (this.state.tabularPromise) { + this.state.tabularPromise.then((data) => { + // Only update the data if the promise resolved before unmounting the component + if (this._isMounted) { + this.setState({ + tabularData: data, + tabularPromise: null, + }); + } + }); + } + } + + componentDidMount() { + this._isMounted = true; + this.props.adapters.data.on('change', this.onUpdateData); + this.finishLoadingData(); + } + + componentWillUnmount() { + this._isMounted = false; + this.props.adapters.data.removeListener('change', this.onUpdateData); + } + + componentDidUpdate() { + this.finishLoadingData(); + } + + renderNoData() { + return ( + + No data available} + body={ + +

The element did not provide any data.

+
+ } + /> +
+ ); + } + + renderLoading() { + return ( + + + + + + + Gathering data … + + + + ); + } + + render() { + if (this.state.tabularPromise) { + return this.renderLoading(); + } else if (!this.state.tabularData) { + return this.renderNoData(); + } + + return ( + + + + ); + } +} + +const DataView = { + title: 'Data', + icon: 'addDataApp', + order: 10, + help: `The data inspector shows the data that is used to draw the visualization + in different formats (if available).`, + shouldShow(adapters) { + return adapters.data; + }, + component: DataViewComponent +}; + +export { DataView }; diff --git a/src/ui/public/inspector/views/data/download_options.js b/src/ui/public/inspector/views/data/download_options.js new file mode 100644 index 0000000000000..770e8035fa17e --- /dev/null +++ b/src/ui/public/inspector/views/data/download_options.js @@ -0,0 +1,86 @@ +import React, { Component } from 'react'; + +import { + EuiButton, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiPopover, +} from '@elastic/eui'; + +import { exportAsCsv } from './lib/export_csv'; + +class DataDownloadOptions extends Component { + + state = { + isPopoverOpen: false, + }; + + onTogglePopover = () => { + this.setState(state => ({ + isPopoverOpen: !state.isPopoverOpen, + })); + }; + + closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + exportAsCsv = () => { + exportAsCsv(`${this.props.title}.csv`, this.props.columns, this.props.rows); + }; + + exportAsRawCsv = () => { + exportAsCsv(`${this.props.title}.csv`, this.props.columns, this.props.rawData); + }; + + render() { + const button = ( + + Download data + + ); + const items = [ + ( + + Formatted CSV + + ) + ]; + if (this.props.rawData) { + items.push( + + Raw CSV + + ); + } + return ( + + + + ); + } +} + +export { DataDownloadOptions }; diff --git a/src/ui/public/inspector/views/data/lib/export_csv.js b/src/ui/public/inspector/views/data/lib/export_csv.js new file mode 100644 index 0000000000000..df262715a891e --- /dev/null +++ b/src/ui/public/inspector/views/data/lib/export_csv.js @@ -0,0 +1,43 @@ +import _ from 'lodash'; +import { saveAs } from '@elastic/filesaver'; +import chrome from 'ui/chrome'; + +function buildCsv(columns, rows) { + const settings = chrome.getUiSettingsClient(); + const csvSeparator = settings.get('csv:separator', ','); + const quoteValues = settings.get('csv:quoteValues', true); + + // const columns = formatted ? $scope.formattedColumns : $scope.table.columns; + const nonAlphaNumRE = /[^a-zA-Z0-9]/; + const allDoubleQuoteRE = /"/g; + + function escape(val) { + if (_.isObject(val)) val = val.valueOf(); + val = String(val); + if (quoteValues && nonAlphaNumRE.test(val)) { + val = `"${val.replace(allDoubleQuoteRE, '""')}"`; + } + return val; + } + + // Build the header row by its names + const header = columns.map(col => escape(col.name)); + + // Convert the array of row objects to an array of row arrays + const orderedFieldNames = columns.map(col => col.field); + const csvRows = rows.map(row => { + return orderedFieldNames.map(field => escape(row[field])); + }); + + return [header, ...csvRows] + .map(row => row.join(csvSeparator)) + .join('\r\n') + + '\r\n'; // Add \r\n after last line +} + +function exportAsCsv(filename, columns, rows) { + const csv = new Blob([buildCsv(columns, rows)], { type: 'text/plain;charset=utf-8' }); + saveAs(csv, filename); +} + +export { exportAsCsv }; diff --git a/src/ui/public/inspector/views/index.js b/src/ui/public/inspector/views/index.js new file mode 100644 index 0000000000000..98a4957c0d6df --- /dev/null +++ b/src/ui/public/inspector/views/index.js @@ -0,0 +1,7 @@ +import { viewRegistry } from './registry'; + +import * as views from './views'; + +Object.values(views).forEach((view) => viewRegistry.register(view)); + +export { viewRegistry }; diff --git a/src/ui/public/inspector/views/registry.js b/src/ui/public/inspector/views/registry.js new file mode 100644 index 0000000000000..02c84de9582e0 --- /dev/null +++ b/src/ui/public/inspector/views/registry.js @@ -0,0 +1,78 @@ +import EventEmitter from 'events'; + +/** + * @callback viewShouldShowFunc + * @param {object} adapters - A list of adapters to check whether or not this view + * should be shown for. + * @returns {boolean} true - if this view should be shown for the given adapters. + */ + +/** + * An object describing an inspector view. + * @typedef {object} InspectorViewDescription + * @property {string} title - The title that will be used to present that view. + * @proeprty {string} icon - An icon name to present this view. Must match an EUI icon. + * @property {ReactComponent} component - The actual React component to render that + * that view. It should always return an `InspectorView` element at the toplevel. + * @property {number} [order=9000] - An order for this view. Views are ordered from lower + * order values to higher order values in the UI. + * @property {string} [help=''] - An help text for this view, that gives a brief description + * of this view. + * @property {viewShouldShowFunc} [shouldShow] - A function, that determines whether + * this view should be visible for a given collection of adapters. If not specified + * the view will always be visible. + */ + +/** + * A registry that will hold inspector views. + */ +class InspectorViewRegistry extends EventEmitter { + _views = []; + + /** + * Register a new inspector view to the registry. Check the README.md in the + * inspector directory for more information of the object format to register + * here. This will also emit a 'change' event on the registry itself. + * + * @param {InspectorViewDescription} view - The view description to add to the registry. + */ + register(view) { + if (!view) return; + this._views.push(view); + // Keep registry sorted by the order property + this._views.sort((a, b) => (a.order || 9000) - (b.order || 9000)); + this.emit('change'); + } + + /** + * Retrieve all views currently registered with the registry. + * @returns {InspectorViewDescription[]} A by `order` sorted list of all registered + * inspector views. + */ + getAll() { + return this._views; + } + + /** + * Retrieve all registered views, that want to be visible for the specified adapters. + * @param {object} adapters - an adapter configuration + * @returns {InspectorViewDescription[]} All inespector view descriptions visible + * for the specific adapters. + */ + getVisible(adapters) { + if (!adapters) { + return []; + } + return this._views.filter(view => + !view.shouldShow || view.shouldShow(adapters) + ); + } +} + +/** + * The global view registry. In the long run this should be solved by a registry + * system introduced by the new platform instead, to not keep global state like that. + */ +const viewRegistry = new InspectorViewRegistry(); + +export { viewRegistry, InspectorViewRegistry }; diff --git a/src/ui/public/inspector/views/registry.test.js b/src/ui/public/inspector/views/registry.test.js new file mode 100644 index 0000000000000..34a7a4073e0f0 --- /dev/null +++ b/src/ui/public/inspector/views/registry.test.js @@ -0,0 +1,76 @@ +import { InspectorViewRegistry } from './registry'; + + +function createMockView(params = {}) { + return { + name: params.name || 'view', + icon: params.icon || 'icon', + help: params.help || 'help text', + component: params.component || (() => {}), + order: params.order, + shouldShow: params.shouldShow, + }; +} + +describe('InspectorViewRegistry', () => { + + let registry; + + beforeEach(() => { + registry = new InspectorViewRegistry(); + }); + + it('should emit a change event when registering a view', () => { + const listener = jest.fn(); + registry.once('change', listener); + registry.register(createMockView()); + expect(listener).toHaveBeenCalled(); + }); + + it('should return views ordered by their order property', () => { + const view1 = createMockView({ name: 'view1', order: 2000 }); + const view2 = createMockView({ name: 'view2', order: 1000 }); + registry.register(view1); + registry.register(view2); + const views = registry.getAll(); + expect(views.map(v => v.name)).toEqual(['view2', 'view1']); + }); + + describe('getVisible()', () => { + it('should return empty array on passing null to the registry', () => { + const view1 = createMockView({ name: 'view1', shouldShow: () => true }); + const view2 = createMockView({ name: 'view2', shouldShow: () => false }); + registry.register(view1); + registry.register(view2); + const views = registry.getVisible(null); + expect(views).toEqual([]); + }); + + it('should only return matching views', () => { + const view1 = createMockView({ name: 'view1', shouldShow: () => true }); + const view2 = createMockView({ name: 'view2', shouldShow: () => false }); + registry.register(view1); + registry.register(view2); + const views = registry.getVisible({}); + expect(views.map(v => v.name)).toEqual(['view1']); + }); + + it('views without shouldShow should be included', () => { + const view1 = createMockView({ name: 'view1', shouldShow: () => true }); + const view2 = createMockView({ name: 'view2' }); + registry.register(view1); + registry.register(view2); + const views = registry.getVisible({}); + expect(views.map(v => v.name)).toEqual(['view1', 'view2']); + }); + + it('should pass the adapters to the callbacks', () => { + const shouldShow = jest.fn(); + const view1 = createMockView({ shouldShow }); + registry.register(view1); + const adapter = { foo: () => {} }; + registry.getVisible(adapter); + expect(shouldShow).toHaveBeenCalledWith(adapter); + }); + }); +}); diff --git a/src/ui/public/inspector/views/requests/details/index.js b/src/ui/public/inspector/views/requests/details/index.js new file mode 100644 index 0000000000000..3d83c75172b4e --- /dev/null +++ b/src/ui/public/inspector/views/requests/details/index.js @@ -0,0 +1,4 @@ +export * from './req_details_description'; +export * from './req_details_request'; +export * from './req_details_response'; +export * from './req_details_stats'; diff --git a/src/ui/public/inspector/views/requests/details/req_details_description.js b/src/ui/public/inspector/views/requests/details/req_details_description.js new file mode 100644 index 0000000000000..5dbcbd82ffe58 --- /dev/null +++ b/src/ui/public/inspector/views/requests/details/req_details_description.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiText, +} from '@elastic/eui'; + +function RequestDetailsDescription(props) { + return ( + + { props.request.description } + + ); +} + +RequestDetailsDescription.shouldShow = (request) => !!request.description; + +RequestDetailsDescription.propTypes = { + request: PropTypes.object.isRequired, +}; + +export { RequestDetailsDescription }; diff --git a/src/ui/public/inspector/views/requests/details/req_details_request.js b/src/ui/public/inspector/views/requests/details/req_details_request.js new file mode 100644 index 0000000000000..2a47b534a61c3 --- /dev/null +++ b/src/ui/public/inspector/views/requests/details/req_details_request.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { + EuiCodeBlock, +} from '@elastic/eui'; + +function RequestDetailsRequest(props) { + return ( + + { JSON.stringify(props.request.json, null, 2) } + + ); +} + +RequestDetailsRequest.shouldShow = (request) => !!request.json; + +export { RequestDetailsRequest }; diff --git a/src/ui/public/inspector/views/requests/details/req_details_response.js b/src/ui/public/inspector/views/requests/details/req_details_response.js new file mode 100644 index 0000000000000..36a0448cdc5fc --- /dev/null +++ b/src/ui/public/inspector/views/requests/details/req_details_response.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { + EuiCodeBlock, +} from '@elastic/eui'; + +function RequestDetailsResponse(props) { + return ( + + { JSON.stringify(props.request.response.json, null, 2) } + + ); +} + +RequestDetailsResponse.shouldShow = (request) => request.response && request.response.json; + +export { RequestDetailsResponse }; diff --git a/src/ui/public/inspector/views/requests/details/req_details_stats.js b/src/ui/public/inspector/views/requests/details/req_details_stats.js new file mode 100644 index 0000000000000..d85a464cf9008 --- /dev/null +++ b/src/ui/public/inspector/views/requests/details/req_details_stats.js @@ -0,0 +1,58 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiIconTip, + EuiTable, + EuiTableBody, + EuiTableRow, + EuiTableRowCell, +} from '@elastic/eui'; + +class RequestDetailsStats extends Component { + + static shouldShow = (request) => !!request.stats && Object.keys(request.stats).length; + + state = { }; + + renderStatRow = (stat) => { + return [ + + + {stat.name} + + {stat.value} + + { stat.description && + + } + + + ]; + }; + + render() { + const { stats } = this.props.request; + const sortedStats = Object.keys(stats).sort().map(name => ({ name, ...stats[name] })); + // TODO: Replace by property once available + return ( + + + { sortedStats.map(this.renderStatRow) } + + + ); + } +} + +RequestDetailsStats.propTypes = { + request: PropTypes.object.isRequired, +}; + +export { RequestDetailsStats }; diff --git a/src/ui/public/inspector/views/requests/request_details.js b/src/ui/public/inspector/views/requests/request_details.js new file mode 100644 index 0000000000000..bb054f396ff1c --- /dev/null +++ b/src/ui/public/inspector/views/requests/request_details.js @@ -0,0 +1,91 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiTab, + EuiTabs, +} from '@elastic/eui'; + +import { + RequestDetailsDescription, + RequestDetailsRequest, + RequestDetailsResponse, + RequestDetailsStats, +} from './details'; + +const DETAILS = [ + { name: 'Description', component: RequestDetailsDescription }, + { name: 'Statistics', component: RequestDetailsStats }, + { name: 'Request', component: RequestDetailsRequest }, + { name: 'Response', component: RequestDetailsResponse }, +]; + +class RequestDetails extends Component { + + constructor(props) { + super(props); + this.state = this.getAvailableDetails(props.request); + } + + selectDetailsTab = (detail) => { + if (detail !== this.state.selectedDetail) { + this.setState({ + selectedDetail: detail + }); + } + }; + + getAvailableDetails(request, prevSelectedDetail) { + const availableDetails = DETAILS.filter(detail => + !detail.component.shouldShow || detail.component.shouldShow(request) + ); + // If the previously selected detail is still available we want to stay + // on this tab and not set another selectedDetail. + if (prevSelectedDetail && availableDetails.includes(prevSelectedDetail)) { + return { availableDetails }; + } + + return { + availableDetails: availableDetails, + selectedDetail: availableDetails[0] + }; + } + + renderDetailTab = (detail) => { + return ( + this.selectDetailsTab(detail)} + > + {detail.name} + + ); + } + + componentWillReceiveProps(props) { + this.setState(this.getAvailableDetails(props.request, this.state.selectedDetail)); + } + + render() { + if (this.state.availableDetails.length === 0) { + return null; + } + const DetailComponent = this.state.selectedDetail.component; + return ( +
+ + { this.state.availableDetails.map(this.renderDetailTab) } + + +
+ ); + } +} + +RequestDetails.propTypes = { + request: PropTypes.object.isRequired, +}; + +export { RequestDetails }; diff --git a/src/ui/public/inspector/views/requests/request_list_entry.js b/src/ui/public/inspector/views/requests/request_list_entry.js new file mode 100644 index 0000000000000..860b82daddccf --- /dev/null +++ b/src/ui/public/inspector/views/requests/request_list_entry.js @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiLoadingSpinner, +} from '@elastic/eui'; + +import { RequestStatus } from '../../adapters'; + + +function RequestListEntry({ request, onClick, isSelected }) { + const status = request.response ? request.response.status : null; + + return ( +
  • + + + + + + + { request.name } + + + + { status && + + {request.time}ms + + } + { !status && } + + +
  • + ); +} + +RequestListEntry.propTypes = { + onClick: PropTypes.func.isRequired, + isSelected: PropTypes.bool, +}; + +export { RequestListEntry }; diff --git a/src/ui/public/inspector/views/requests/requests_inspector.less b/src/ui/public/inspector/views/requests/requests_inspector.less new file mode 100644 index 0000000000000..9332d62496409 --- /dev/null +++ b/src/ui/public/inspector/views/requests/requests_inspector.less @@ -0,0 +1,15 @@ +.requests-inspector__req-name { + text-align: left; +} + +.requests-details__description { + padding: 16px; +} + +.requests-inspector__empty { + text-align: center; +} + +.requests-stats__description { + min-width: 300px; +} diff --git a/src/ui/public/inspector/views/requests/requests_view.js b/src/ui/public/inspector/views/requests/requests_view.js new file mode 100644 index 0000000000000..9f6fc00d7ebaf --- /dev/null +++ b/src/ui/public/inspector/views/requests/requests_view.js @@ -0,0 +1,132 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiEmptyPrompt, + EuiSpacer, +} from '@elastic/eui'; + +import { InspectorView } from '../..'; + +import { RequestDetails } from './request_details'; +import { RequestListEntry } from './request_list_entry'; + +import './requests_inspector.less'; + +class RequestsViewComponent extends Component { + + constructor(props) { + super(props); + this.requests = props.adapters.requests; + this.requests.on('change', this._onRequestsChange); + + const requests = this.requests.getRequests(); + this.state = { + requests: requests, + request: requests.length ? requests[0] : null + }; + } + + _onRequestsChange = () => { + const requests = this.requests.getRequests(); + this.setState({ requests }); + if (!requests.includes(this.state.request)) { + this.setState({ + request: requests.length ? requests[0] : null + }); + } + } + + selectRequest(request) { + if (request !== this.state.request) { + this.setState({ request }); + } + } + + _renderRequest = (req, index) => { + return ( + this.selectRequest(req)} + /> + ); + }; + + componentWillReceiveProps(props) { + if (props.vis !== this.props.vis) { + // Vis is about to change. Remove listener from the previous vis requests + // logger and attach it to the new requests logger. + this.requests.removeListener('change', this._onRequestsChange); + this.requests = props.vis.API.inspectorAdapters.requests; + this.requests.on('change', this._onRequestsChange); + const requests = this.requests.getRequests(); + // Also write the new vis requests to the state. + this.setState({ + requests: requests, + request: requests.length ? requests[0] : null + }); + } + } + + componentWillUnmount() { + this.requests.removeListener('change', this._onRequestsChange); + } + + renderEmptyRequests() { + return ( + + No requests logged} + body={ + +

    The element hasn't logged any requests (yet).

    +

    + This usually means that there was no need to fetch any data or + that the element has not yet started fetching data. +

    +
    + } + /> +
    + ); + } + + render() { + if (!this.state.requests || !this.state.requests.length) { + return this.renderEmptyRequests(); + } + + return ( + +
      + { this.state.requests.map(this._renderRequest) } +
    + + { this.state.request && + + } +
    + ); + } +} + +RequestsViewComponent.propTypes = { + adapters: PropTypes.object.isRequired, +}; + +const RequestsView = { + title: 'Requests', + icon: 'apmApp', + order: 20, + help: `The requests inspector allows you to inspect the requests the visualization + did to collect its data.`, + shouldShow(adapters) { + return adapters.requests; + }, + component: RequestsViewComponent +}; + +export { RequestsView }; diff --git a/src/ui/public/inspector/views/views.js b/src/ui/public/inspector/views/views.js new file mode 100644 index 0000000000000..ef3d3559bce64 --- /dev/null +++ b/src/ui/public/inspector/views/views.js @@ -0,0 +1,2 @@ +export { DataView } from './data/data_view'; +export { RequestsView } from './requests/requests_view'; diff --git a/src/ui/public/vis/__tests__/_vis.js b/src/ui/public/vis/__tests__/_vis.js index a1ab4d95aff66..03b41f019bf24 100644 --- a/src/ui/public/vis/__tests__/_vis.js +++ b/src/ui/public/vis/__tests__/_vis.js @@ -1,9 +1,12 @@ import _ from 'lodash'; import ngMock from 'ng_mock'; +import sinon from 'sinon'; import expect from 'expect.js'; import { VisProvider } from '..'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; import { VisTypesRegistryProvider } from '../../registry/vis_types'; +import { DataAdapter, RequestAdapter } from '../../inspector/adapters'; +import * as Inspector from '../../inspector/inspector'; describe('Vis Class', function () { let indexPattern; @@ -87,4 +90,148 @@ describe('Vis Class', function () { }); }); + describe('inspector', () => { + + // Wrap the given vis type definition in a state, that can be passed to vis + const state = (type) => ({ + type: { + visConfig: { defaults: {} }, + ...type, + } + }); + + describe('hasInspector()', () => { + it('should forward to inspectors hasInspector', () => { + const vis = new Vis(indexPattern, state({ + inspectorAdapters: { + data: true, + requests: true, + } + })); + sinon.spy(Inspector, 'hasInspector'); + vis.hasInspector(); + expect(Inspector.hasInspector.calledOnce).to.be(true); + const adapters = Inspector.hasInspector.lastCall.args[0]; + expect(adapters.data).to.be.a(DataAdapter); + expect(adapters.requests).to.be.a(RequestAdapter); + }); + + it('should return hasInspectors result', () => { + const vis = new Vis(indexPattern, state({})); + const stub = sinon.stub(Inspector, 'hasInspector'); + stub.returns(true); + expect(vis.hasInspector()).to.be(true); + stub.returns(false); + expect(vis.hasInspector()).to.be(false); + }); + + afterEach(() => { + Inspector.hasInspector.restore(); + }); + }); + + describe('openInspector()', () => { + it('should call openInspector with all attached inspectors', () => { + const Foodapter = class {}; + const vis = new Vis(indexPattern, state({ + inspectorAdapters: { + data: true, + custom: { + foo: Foodapter + } + } + })); + sinon.spy(Inspector, 'openInspector'); + vis.openInspector(); + expect(Inspector.openInspector.calledOnce).to.be(true); + const adapters = Inspector.openInspector.lastCall.args[0]; + expect(adapters).to.be(vis.API.inspectorAdapters); + }); + + it('should pass the vis title to the openInspector call', () => { + const vis = new Vis(indexPattern, { ...state(), title: 'beautifulVis' }); + sinon.spy(Inspector, 'openInspector'); + vis.openInspector(); + expect(Inspector.openInspector.calledOnce).to.be(true); + const params = Inspector.openInspector.lastCall.args[1]; + expect(params.title).to.be('beautifulVis'); + }); + + afterEach(() => { + Inspector.openInspector.restore(); + }); + }); + + describe('inspectorAdapters', () => { + + it('should register none for none requestHandler', () => { + const vis = new Vis(indexPattern, state({ requestHandler: 'none' })); + expect(vis.API.inspectorAdapters).to.eql({}); + }); + + it('should attach data and request handler for courier', () => { + const vis = new Vis(indexPattern, state({ requestHandler: 'courier' })); + expect(vis.API.inspectorAdapters.data).to.be.a(DataAdapter); + expect(vis.API.inspectorAdapters.requests).to.be.a(RequestAdapter); + }); + + it('should allow enabling data adapter manually', () => { + const vis = new Vis(indexPattern, state({ + requestHandler: 'none', + inspectorAdapters: { + data: true, + } + })); + expect(vis.API.inspectorAdapters.data).to.be.a(DataAdapter); + }); + + it('should allow enabling requests adapter manually', () => { + const vis = new Vis(indexPattern, state({ + requestHandler: 'none', + inspectorAdapters: { + requests: true, + } + })); + expect(vis.API.inspectorAdapters.requests).to.be.a(RequestAdapter); + }); + + it('should allow adding custom inspector adapters via the custom key', () => { + const Foodapter = class {}; + const Bardapter = class {}; + const vis = new Vis(indexPattern, state({ + requestHandler: 'none', + inspectorAdapters: { + custom: { + foo: Foodapter, + bar: Bardapter, + } + } + })); + expect(vis.API.inspectorAdapters.foo).to.be.a(Foodapter); + expect(vis.API.inspectorAdapters.bar).to.be.a(Bardapter); + }); + + it('should not share adapter instances between vis instances', () => { + const Foodapter = class {}; + const visState = state({ + inspectorAdapters: { + data: true, + custom: { + foo: Foodapter + } + } + }); + const vis1 = new Vis(indexPattern, visState); + const vis2 = new Vis(indexPattern, visState); + expect(vis1.API.inspectorAdapters.foo).to.be.a(Foodapter); + expect(vis2.API.inspectorAdapters.foo).to.be.a(Foodapter); + expect(vis1.API.inspectorAdapters.foo).not.to.be(vis2.API.inspectorAdapters.foo); + expect(vis1.API.inspectorAdapters.data).to.be.a(DataAdapter); + expect(vis2.API.inspectorAdapters.data).to.be.a(DataAdapter); + expect(vis1.API.inspectorAdapters.data).not.to.be(vis2.API.inspectorAdapters.data); + }); + }); + + }); + }); diff --git a/src/ui/public/vis/request_handlers/courier.js b/src/ui/public/vis/request_handlers/courier.js index 126f7a6971342..42440f44dbcb4 100644 --- a/src/ui/public/vis/request_handlers/courier.js +++ b/src/ui/public/vis/request_handlers/courier.js @@ -1,10 +1,62 @@ import _ from 'lodash'; import { SearchSourceProvider } from '../../courier/data_source/search_source'; import { VisRequestHandlersRegistryProvider } from '../../registry/vis_request_handlers'; +import { getRequestInspectorStats, getResponseInspectorStats } from '../../courier/utils/courier_inspector_utils'; +import { tabifyAggResponse } from '../../agg_response/tabify/tabify'; const CourierRequestHandlerProvider = function (Private, courier, timefilter) { const SearchSource = Private(SearchSourceProvider); + /** + * This function builds tabular data from the response and attaches it to the + * inspector. It will only be called when the data view in the inspector is opened. + */ + async function buildTabularInspectorData(vis, searchSource) { + const table = tabifyAggResponse(vis.getAggConfig().getResponseAggs(), searchSource.finalResponse, { + canSplit: false, + asAggConfigResults: false, + partialRows: true, + }); + const columns = table.columns.map((col, index) => { + const field = col.aggConfig.getField(); + const isCellContentFilterable = + col.aggConfig.isFilterable() + && (!field || field.filterable); + return ({ + name: col.title, + field: `col${index}`, + filter: isCellContentFilterable && ((value) => { + const filter = col.aggConfig.createFilter(value); + vis.API.queryFilter.addFilters(filter); + }), + filterOut: isCellContentFilterable && ((value) => { + const filter = col.aggConfig.createFilter(value); + filter.meta = filter.meta || {}; + filter.meta.negate = true; + vis.API.queryFilter.addFilters(filter); + }), + }); + }); + const rows = []; + const rowsRaw = []; + table.rows.forEach(row => { + const { formatted, raw } = row.reduce((prev, cur, index) => { + prev.raw[`col${index}`] = cur; + const fieldFormatter = table.columns[index].aggConfig.fieldFormatter('text'); + prev.formatted[`col${index}`] = fieldFormatter(cur); + return prev; + }, { formatted: {}, raw: {} }); + rows.push(formatted); + rowsRaw.push(raw); + }); + + return await new Promise(resolve => { + setTimeout(() => resolve({ columns, rows, rowsRaw }), 3000); + }); + + return { columns, rows, rowsRaw }; + } + /** * TODO: This code can be removed as soon as we got rid of inheritance in the * searchsource and pass down every filter explicitly. @@ -96,6 +148,11 @@ const CourierRequestHandlerProvider = function (Private, courier, timefilter) { return new Promise((resolve, reject) => { if (shouldQuery()) { delete vis.reload; + + vis.API.inspectorAdapters.requests.reset(); + const request = vis.API.inspectorAdapters.requests.start('Data request'); + request.stats(getRequestInspectorStats(requestSearchSource)); + requestSearchSource.onResults().then(resp => { searchSource.lastQuery = { filter: _.cloneDeep(searchSource.get('filter')), @@ -104,6 +161,10 @@ const CourierRequestHandlerProvider = function (Private, courier, timefilter) { timeRange: _.cloneDeep(timeRange) }; + request + .stats(getResponseInspectorStats(searchSource, resp)) + .ok({ json: resp }); + searchSource.rawResponse = resp; return _.cloneDeep(resp); @@ -116,9 +177,16 @@ const CourierRequestHandlerProvider = function (Private, courier, timefilter) { } searchSource.finalResponse = resp; + + vis.API.inspectorAdapters.data.setTabularLoader(() => buildTabularInspectorData(vis, searchSource)); + resolve(resp); }).catch(e => reject(e)); + searchSource.getSearchRequestBody().then(req => { + request.json(req); + }); + courier.fetch(); } else { resolve(searchSource.finalResponse); diff --git a/src/ui/public/vis/vis.js b/src/ui/public/vis/vis.js index 439ddbd5fe5d7..c081549dd80fe 100644 --- a/src/ui/public/vis/vis.js +++ b/src/ui/public/vis/vis.js @@ -21,6 +21,9 @@ import { queryManagerFactory } from '../query_manager'; import { SearchSourceProvider } from '../courier/data_source/search_source'; import { SavedObjectsClientProvider } from '../saved_objects'; +import { openInspector, hasInspector } from '../inspector'; +import { RequestAdapter, DataAdapter } from '../inspector/adapters'; + export function VisProvider(Private, Promise, indexPatterns, timefilter, getAppState) { const visTypes = Private(VisTypesRegistryProvider); const brushEvent = Private(UtilsBrushEventProvider); @@ -69,10 +72,60 @@ export function VisProvider(Private, Promise, indexPatterns, timefilter, getAppS throw new Error('Unable to inherit search source, visualize saved object does not have search source.'); } return new SearchSource().inherits(parentSearchSource); - } + }, + inspectorAdapters: this._getActiveInspectorAdapters(), }; } + /** + * Open the inspector for this visualization. + * @return {InspectorSession} the handler for the session of this inspector. + */ + openInspector() { + return openInspector(this.API.inspectorAdapters, { + title: this.title + }); + } + + hasInspector() { + return hasInspector(this.API.inspectorAdapters); + } + + /** + * Returns an object of all inspectors for this vis object. + * This must only be called after this.type has properly be initialized, + * since we need to read out data from the the vis type to check which + * inspectors are available. + */ + _getActiveInspectorAdapters() { + const adapters = {}; + + // Add the requests inspector adapters if the vis type explicitly requested it via + // inspectorAdapters.requests: true in its definition or if it's using the courier + // request handler, since that will automatically log its requests. + if (this.type.inspectorAdapters && this.type.inspectorAdapters.requests + || this.type.requestHandler === 'courier') { + adapters.requests = new RequestAdapter(); + } + + // Add the data inspector adapter if the vis type requested it or if the + // vis is using courier, since we know that courier supports logging + // its data. + if (this.type.inspectorAdapters && this.type.inspectorAdapters.data + || this.type.requestHandler === 'courier') { + adapters.data = new DataAdapter(); + } + + // Add all inspectors, that are explicitly registered with this vis type + if (this.type.inspectorAdapters && this.type.inspectorAdapters.custom) { + Object.entries(this.type.inspectorAdapters.custom).forEach(([key, Adapter]) => { + adapters[key] = new Adapter(); + }); + } + + return adapters; + } + isEditorMode() { return this.editorMode || false; } diff --git a/src/ui/public/visualize/loader/loader.js b/src/ui/public/visualize/loader/loader.js index 6742247df8c4f..d83460d3763c6 100644 --- a/src/ui/public/visualize/loader/loader.js +++ b/src/ui/public/visualize/loader/loader.js @@ -23,10 +23,6 @@ import { EmbeddedVisualizeHandler } from './embedded_visualize_handler'; * @property {object} timeRange An object with a from/to key, that must be * either a date in ISO format, or a valid datetime Elasticsearch expression, * e.g.: { from: 'now-7d/d', to: 'now' } - * @property {boolean} showSpyPanel Whether or not the spy panel should be available - * on this chart. If set to true, spy panels will only be shown if there are - * spy panels available for this specific visualization, since not every visualization - * supports all spy panels. (default: false) * @property {boolean} append If set to true, the visualization will be appended * to the passed element instead of replacing all its content. (default: false) * @property {string} cssClass If specified this CSS class (or classes with space separated) @@ -43,7 +39,6 @@ const VisualizeLoaderProvider = ($compile, $rootScope, savedVisualizations) => { scope.appState = params.appState; scope.uiState = params.uiState; scope.timeRange = params.timeRange; - scope.showSpyPanel = params.showSpyPanel; const container = angular.element(el); diff --git a/src/ui/public/visualize/loader/loader_template.html b/src/ui/public/visualize/loader/loader_template.html index a9e3269f1471b..1e7380d34d84c 100644 --- a/src/ui/public/visualize/loader/loader_template.html +++ b/src/ui/public/visualize/loader/loader_template.html @@ -3,6 +3,5 @@ app-state="appState" ui-state="uiState" time-range="timeRange" - show-spy-panel="showSpyPanel" render-complete > diff --git a/src/ui/public/visualize/spy.js b/src/ui/public/visualize/spy.js index 14bc334ad35cd..e92e471dd01ff 100644 --- a/src/ui/public/visualize/spy.js +++ b/src/ui/public/visualize/spy.js @@ -1,4 +1,6 @@ import $ from 'jquery'; +import { render, unmountComponentAtNode } from 'react-dom'; +import React from 'react'; import { SpyModesRegistryProvider } from '../registry/spy_modes'; import { uiModules } from '../modules'; import spyTemplate from './spy.html'; @@ -135,6 +137,17 @@ uiModules $scope.visElement.toggleClass('spy-only', $scope.maximizedSpy || $scope.forceMaximized); }); + /** + * Renders the currently active spy via its React component to the DOM. + * This method must only be called if the current spy is a react spy. + */ + function renderReactSpy() { + render( + React.createElement(currentSpy.component, { vis: $scope.vis }), + currentSpy.$container[0] + ); + } + /** * Watch for changes of the currentMode. Whenever it changes, we render * the new mode into the template. Therefore we remove the previously rendered @@ -149,10 +162,18 @@ uiModules const newMode = spyModes.byName[mode]; if (currentSpy) { - // If we already have a spy loaded, remove that HTML element and - // destroy the previous Angular scope. + // In case we already had a spy loaded, we clean it up first. + if (currentSpy.$scope) { + // In case of an Angular spy, destroy the scope + currentSpy.$scope.$destroy(); + } else { + // In case of a react spy, unregister the vis $scope watch + // and unmount the React component. + currentSpy.visWatch(); + unmountComponentAtNode(currentSpy.$container[0]); + } + // Remove the actual container element. currentSpy.$container.remove(); - currentSpy.$scope.$destroy(); currentSpy = null; } @@ -163,19 +184,35 @@ uiModules return; } - const contentScope = $scope.$new(); const contentContainer = $('
    '); - contentContainer.append($compile(newMode.template)(contentScope)); - - $container.append(contentContainer); - currentSpy = { - $scope: contentScope, - $container: contentContainer, - mode: mode, - }; + if (newMode.component) { + // Render via react + // In case $scope.vis updates, we need to rerender the component + const visWatch = $scope.$watch('vis', renderReactSpy); + currentSpy = { + component: newMode.component, + visWatch: visWatch, + $container: contentContainer, + mode: mode, + }; + $container.append(contentContainer); + renderReactSpy(); + } else { + // Render via Angular + const contentScope = $scope.$new(); + contentContainer.append($compile(newMode.template)(contentScope)); + + currentSpy = { + $scope: contentScope, + $container: contentContainer, + mode: mode, + }; + + $container.append(contentContainer); + newMode.link && newMode.link(currentSpy.$scope, currentSpy.$element); + } - newMode.link && newMode.link(currentSpy.$scope, currentSpy.$element); }); } }; diff --git a/src/ui/public/visualize/visualization.html b/src/ui/public/visualize/visualization.html index e73668db0fe91..e117d90d766fa 100644 --- a/src/ui/public/visualize/visualization.html +++ b/src/ui/public/visualize/visualization.html @@ -20,6 +20,13 @@

    No results found

    ng-class="{ loading: vis.type.requiresSearch && searchSource.activeFetchCount > 0 }" class="visualize-chart">
    + { + vis.openInspector().bindToAngularScope($scope); + }; + $scope.visElement = getVisContainer(); const loadingDelay = config.get('visualization:loadingDelay'); diff --git a/src/ui/public/visualize/visualize.js b/src/ui/public/visualize/visualize.js index d31816e1d51d5..6a7bc385767c8 100644 --- a/src/ui/public/visualize/visualize.js +++ b/src/ui/public/visualize/visualize.js @@ -92,8 +92,8 @@ uiModules requestHandler($scope.vis, handlerParams) .then(requestHandlerResponse => { - //No need to call the response handler when there have been no data nor has been there changes - //in the vis-state (response handler does not depend on uiStat + //No need to call the response handler when there have been no data nor has been there changes + //in the vis-state (response handler does not depend on uiStat const canSkipResponseHandler = ( $scope.previousRequestHandlerResponse && $scope.previousRequestHandlerResponse === requestHandlerResponse && $scope.previousVisState && _.isEqual($scope.previousVisState, $scope.vis.getState()) diff --git a/src/ui/ui_exports/ui_export_types/index.js b/src/ui/ui_exports/ui_export_types/index.js index 1d60f002702e3..ec5ac5fb66163 100644 --- a/src/ui/ui_exports/ui_export_types/index.js +++ b/src/ui/ui_exports/ui_export_types/index.js @@ -21,6 +21,7 @@ export { embeddableFactories, fieldFormats, fieldFormatEditors, + inspectorViews, spyModes, chromeNavControls, navbarExtensions, diff --git a/src/ui/ui_exports/ui_export_types/ui_app_extensions.js b/src/ui/ui_exports/ui_export_types/ui_app_extensions.js index edfa103e264e8..7adcfab195204 100644 --- a/src/ui/ui_exports/ui_export_types/ui_app_extensions.js +++ b/src/ui/ui_exports/ui_export_types/ui_app_extensions.js @@ -30,6 +30,7 @@ export const devTools = appExtension; export const docViews = appExtension; export const hacks = appExtension; export const home = appExtension; +export const inspectorViews = appExtension; // aliases visTypeEnhancers to the visTypes group export const visTypeEnhancers = wrap(alias('visTypes'), appExtension); diff --git a/tasks/config/run.js b/tasks/config/run.js index 7c2cfa091db83..f8b221545dda2 100644 --- a/tasks/config/run.js +++ b/tasks/config/run.js @@ -104,7 +104,6 @@ module.exports = function (grunt) { flags: [ ...funcTestServerFlags, '--dev', - '--dev_mode.enabled=false', '--no-base-path', '--optimize.watchPort=5611', '--optimize.watchPrebuild=true', diff --git a/yarn.lock b/yarn.lock index 39c3a862a6491..1fa7aec903dc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10280,6 +10280,15 @@ react-dom@^16.0.0, react-dom@^16.2.0: object-assign "^4.1.1" prop-types "^15.6.0" +react-dom@^16.3.0: + version "16.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.3.1.tgz#6a3c90a4fb62f915bdbcf6204422d93a7d4ca573" + dependencies: + fbjs "^0.8.16" + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.0" + react-draggable@3.x, "react-draggable@^2.2.6 || ^3.0.3": version "3.0.5" resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.5.tgz#c031e0ed4313531f9409d6cd84c8ebcec0ddfe2d" @@ -10491,6 +10500,15 @@ react@>=0.13.3, react@^16.2.0: object-assign "^4.1.1" prop-types "^15.6.0" +react@^16.3.0: + version "16.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-16.3.1.tgz#4a2da433d471251c69b6033ada30e2ed1202cfd8" + dependencies: + fbjs "^0.8.16" + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.0" + reactcss@1.2.3, reactcss@^1.2.0: version "1.2.3" resolved "https://registry.yarnpkg.com/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd"