[6.x] Replace spy panels by Inspector (#16387) | Fix one inspector test (#20072) (#20076)

* Replace spy panels by Inspector (#16387)

* Add Inspector feature

* So long, and thanks for all the fish, spy panel

* Fix several functional tests

* Fix unit tests

* Fix spy panel button tests

* Replace old spy panel documentation

* Disable test temporarily until we have dashboard triggers

* Enter edit mode for dark theme test

* Fix some more functional tests

* Fix more functional tests

* More test fixing

* Fix more functional tests

* Allow opening the inspector via loader handler

* Refactor InspectorViewChooser, remove unused CSS

* Remove dead code

* Fix data download button style

* Remove redundant code

* Load inspectorViews for dashboard_viewer

* Extract inspector views to custom core_plugin

* Switch API to TypeScript 🎉

* Design changes

* Remove icons from views

* Design changes

* Improve typings of API

* Add typing to all adapters

* Show loading spinner in request selector

* Rewrite InspectorView to TypeScript

* Fix help text for data view

* Remove deprecated React lifecycle methods

* Embed inspector into dashboard panel actions

* Remove temporary inspector trigger

* Remove old CSS

* Fix dashboard trigger for new panel action

* Add tests for InspectorPanel and DataAdapter

* Produce a hierarchical table if the vis is hierarchical

* Remove allowJs option again

* Add missing Apache license headers

* Close inspector on dashboard when navigating away

* Use proper title for dashboard panels

* Fix functional tests

* Skip broken test for now

* Flush view chooser button

* Add request adapter tests

* Skip more tests, broken due to typescript

* Add Request Time description

* Add description for courier request

* Fix tests

* Replace icon by new (not yet released) icon

* Finalize design of inspector

* Remove discover test, that relied on spy panels

* Change API to be properly mockable in tests

* Add aria-live region for request status

* Replace old method in functional tests

* Replace abitrary magic number

* Use object destructuring in vis

* Fix issue with crashing requests view

* Add request time tooltip

* Get request body of correct search source

* Make filter buttons properly keyboard accessible

* Follow Dave's design suggestions

* Remove redundant request from name

* Remove unneeded comments

* WIP raw-formatted values

* Fix filtering issue

* Fix tests and more license headers

* Add data view tests

* Remove search from table

* Fix typos

* Implement review suggestion

* Remove artificial delays for testing

* Fix new panel action structure

* Minor design adjustments

* Fix failing functional test

* Update failing snapshot test

* Implement final wording

* Apply new EUI styling

* Fix closing inspector in tests

* Fix sorting of table

* Align punctuation between tooltips

* Fix test that doesn't exist on master

* Fix one inspector test (#20072)

* Remove file that came accidentally back due to merging

* Fix accidental merge paste

* Fix 6.4 test failures
This commit is contained in:
Tim Roes 2018-06-21 11:45:13 +02:00 committed by GitHub
parent 5d29b10412
commit 9ba0689dcd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
126 changed files with 4221 additions and 1502 deletions

View file

@ -9,7 +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.
| spyModes | Modules that register providers with the `ui/registry/spy_modes` registry.
| inspectorViews | Modules that register custom inspector views via the `viewRegistry` in `ui/inspector`.
| 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.
| docViews | Modules that register providers with the `ui/registry/doc_views` registry.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

View file

@ -162,6 +162,6 @@ include::visualize/tagcloud.asciidoc[]
include::visualize/heatmap.asciidoc[]
include::visualize/visualization-raw-data.asciidoc[]
include::visualize/vega.asciidoc[]
include::visualize/inspector.asciidoc[]

View file

@ -0,0 +1,17 @@
[[vis-inspector]]
== Inspector
Many visualizations have an Inspector that can help you gain insight to the data
behind the visualization.
To open the Inspector use the Inspector button while in the editor or select Inspector
from the Dashboard panel menu.
From the Inspector, you can download the visualization data as a comma separated
values (CSV) file in Formatted or Raw format. Formatted downloads the data in table format.
Raw downloads the data as provided -- dates are timestamps, numbers dont have
thousand separators, and so on.
To view the requests that collected the data, select Requests from the View menu in the upper right.
Which views are available depends on the inspected visualization.

View file

@ -1,29 +0,0 @@
[[vis-spy]]
== Visualization Spy
To display the raw data behind the visualization, click the image:images/spy-open-button.png[] button in the bottom left corner of the container. The visualization spy panel will open. Use the select input to view detailed information about the raw data.
image:images/spy-panel.png[]
.Table
A representation of the underlying data, presented as a paginated data grid. You can sort the items
in the table by clicking on the table headers at the top of each column.
.Request
The raw request used to query the server, presented in JSON format.
.Response
The raw response from the server, presented in JSON format.
.Statistics
A summary of the statistics related to the request and the response, presented as a data grid. The data
grid includes the query duration, the request duration, the total number of records found on the server, and the
index pattern used to make the query.
.Debug
The visualization saved state presented in JSON format.
To export the raw data behind the visualization as a comma-separated-values (CSV) file, click on either the
*Raw* or *Formatted* links at the bottom of any of the detailed information tabs. A raw export contains the data as it
is stored in Elasticsearch. A formatted export contains the results of any applicable Kibana
<<managing-fields,field formatters>>.

View file

@ -225,6 +225,8 @@
"@kbn/eslint-plugin-license-header": "link:packages/kbn-eslint-plugin-license-header",
"@kbn/plugin-generator": "link:packages/kbn-plugin-generator",
"@kbn/test": "link:packages/kbn-test",
"@types/angular": "^1.6.45",
"@types/classnames": "^2.2.3",
"@types/eslint": "^4.16.2",
"@types/execa": "^0.9.0",
"@types/getopts": "^2.0.0",

View file

@ -1,4 +0,0 @@
{
"name": "dev_mode",
"version": "kibana"
}

View file

@ -1,23 +0,0 @@
<div>
<div class="euiFlexGroup euiFlexGroup--responsive euiFlexGroup--gutterMedium">
<div class="euiFlexItem">
<h3 class="euiTitle euiTitle--small">
Vis State
</h3>
<div class="euiCodeBlock euiCodeBlock--light euiCodeBlock--fontSmall euiCodeBlock--paddingSmall">
<code class="euiCodeBlock__code">
<pre class="euiCodeBlock__pre">{{vis.getEnabledState() | json}}</pre>
</code>
</div>
</div>
<div class="euiFlexItem">
<h3 class="euiTitle euiTitle--small">Details</h3>
<dl class="euiDescriptionList euiDescriptionList--column">
<dt class="euiDescriptionList__title">Type Name</dt>
<dd class="euiDescriptionList__description">{{vis.type.name}}</dd>
<dt class="euiDescriptionList__title">Hierarchical Data</dt>
<dd class="euiDescriptionList__description">{{vis.isHierarchical()}}</dd>
</dl>
</div>
</div>
</div>

View file

@ -20,9 +20,8 @@
export default function (kibana) {
return new kibana.Plugin({
uiExports: {
spyModes: [
'plugins/spy_modes/table_spy_mode',
'plugins/spy_modes/req_resp_stats_spy_mode'
inspectorViews: [
'plugins/inspector_views/register_views'
]
}
});

View file

@ -0,0 +1,4 @@
{
"name": "inspector_views",
"version": "kibana"
}

View file

@ -0,0 +1,200 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Inspector Data View component should render empty state 1`] = `
<DataViewComponent
adapters={
Object {
"data": DataAdapter {
"_events": Object {
"change": [Function],
},
"_eventsCount": 1,
"_maxListeners": undefined,
"domain": null,
"tabular": [Function],
"tabularOptions": Object {},
},
}
}
title="Test Data"
>
<InspectorView
useFlex={true}
>
<EuiFlyoutBody
className="inspector-view__flex"
>
<div
className="euiFlyoutBody inspector-view__flex"
>
<EuiEmptyPrompt
body={
<UNDEFINED>
<p>
The element did not provide any data.
</p>
</UNDEFINED>
}
iconColor="subdued"
title={
<h2>
No data available
</h2>
}
>
<div
className="euiEmptyPrompt"
>
<EuiTextColor
color="subdued"
>
<span
className="euiTextColor euiTextColor--subdued"
>
<EuiTitle
size="m"
>
<h2
className="euiTitle euiTitle--medium"
>
No data available
</h2>
</EuiTitle>
<EuiSpacer
size="m"
>
<div
className="euiSpacer euiSpacer--m"
/>
</EuiSpacer>
<EuiText
grow={true}
>
<div
className="euiText"
>
<p>
The element did not provide any data.
</p>
</div>
</EuiText>
<EuiSpacer
size="l"
>
<div
className="euiSpacer euiSpacer--l"
/>
</EuiSpacer>
</span>
</EuiTextColor>
</div>
</EuiEmptyPrompt>
</div>
</EuiFlyoutBody>
</InspectorView>
</DataViewComponent>
`;
exports[`Inspector Data View component should render loading state 1`] = `
<DataViewComponent
adapters={
Object {
"data": DataAdapter {
"_events": Object {
"change": [Function],
},
"_eventsCount": 1,
"_maxListeners": undefined,
"domain": null,
},
}
}
title="Test Data"
>
<InspectorView
useFlex={true}
>
<EuiFlyoutBody
className="inspector-view__flex"
>
<div
className="euiFlyoutBody inspector-view__flex"
>
<EuiFlexGroup
alignItems="center"
component="div"
direction="row"
gutterSize="l"
justifyContent="center"
responsive={true}
wrap={false}
>
<div
className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<EuiFlexItem
component="div"
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<EuiPanel
className="eui-textCenter"
grow={true}
hasShadow={false}
paddingSize="m"
>
<div
className="euiPanel euiPanel--paddingMedium eui-textCenter"
>
<EuiLoadingChart
mono={false}
size="m"
>
<div
className="euiLoadingChart euiLoadingChart--medium"
>
<div
className="euiLoadingChart__bar"
/>
<div
className="euiLoadingChart__bar"
/>
<div
className="euiLoadingChart__bar"
/>
<div
className="euiLoadingChart__bar"
/>
</div>
</EuiLoadingChart>
<EuiSpacer
size="s"
>
<div
className="euiSpacer euiSpacer--s"
/>
</EuiSpacer>
<EuiText
grow={true}
>
<div
className="euiText"
>
<p>
Gathering data
</p>
</div>
</EuiText>
</div>
</EuiPanel>
</div>
</EuiFlexItem>
</div>
</EuiFlexGroup>
</div>
</EuiFlyoutBody>
</InspectorView>
</DataViewComponent>
`;

View file

@ -0,0 +1,144 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import './data_table.less';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiSpacer,
EuiToolTip,
} from '@elastic/eui';
import { DataDownloadOptions } from './download_options';
class DataTableFormat extends Component {
state = { };
static renderCell(col, value, isFormatted) {
return (
<EuiFlexGroup
gutterSize="s"
alignItems="center"
>
<EuiFlexItem grow={false}>
{ isFormatted ? value.formatted : value }
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup
gutterSize="none"
alignItems="center"
>
{ col.filter &&
<EuiToolTip
position="bottom"
content="Filter for value"
>
<EuiButtonIcon
iconType="plusInCircle"
color="text"
aria-label="Filter for value"
className="inspector-table__filter"
onClick={() => col.filter(value)}
/>
</EuiToolTip>
}
{ col.filterOut &&
<EuiFlexItem grow={false}>
<EuiToolTip
position="bottom"
content="Filter out value"
>
<EuiButtonIcon
iconType="minusInCircle"
color="text"
aria-label="Filter out value"
className="inspector-table__filter"
onClick={() => col.filterOut(value)}
/>
</EuiToolTip>
</EuiFlexItem>
}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}
static getDerivedStateFromProps({ data, isFormatted }) {
if (!data) {
return {
columns: null,
rows: null,
};
}
const columns = data.columns.map(col => ({
name: col.name,
field: col.field,
sortable: isFormatted ? row => row[col.field].raw : true,
render: (value) => DataTableFormat.renderCell(col, value, isFormatted),
}));
return { columns, rows: data.rows };
}
render() {
const { columns, rows } = this.state;
return (
<React.Fragment>
<EuiFlexGroup>
<EuiFlexItem grow={true} />
<EuiFlexItem grow={false}>
<DataDownloadOptions
columns={this.state.columns}
rows={this.state.rows}
isFormatted={this.props.isFormatted}
title={this.props.exportTitle}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiInMemoryTable
data-test-subj="inspectorTable"
columns={columns}
items={rows}
sorting={true}
pagination={true}
compressed={true}
/>
</React.Fragment>
);
}
}
DataTableFormat.propTypes = {
data: PropTypes.object.isRequired,
exportTitle: PropTypes.string.isRequired,
isFormatted: PropTypes.bool,
};
export { DataTableFormat };

View file

@ -0,0 +1,8 @@
.inspector-table__filter {
opacity: 0;
}
tr:hover .inspector-table__filter,
.inspector-table__filter:focus {
opacity: 1;
}

View file

@ -0,0 +1,160 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Component } from 'react';
import {
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingChart,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { InspectorView } from 'ui/inspector';
import {
DataTableFormat,
} from './data_table';
class DataViewComponent extends Component {
_isMounted = false;
state = {
tabularData: null,
tabularOptions: {},
tabularLoader: null,
}
static getDerivedStateFromProps(nextProps) {
return {
tabularData: null,
tabularOptions: {},
tabularPromise: nextProps.adapters.data.getTabular(),
};
}
onUpdateData = (type) => {
if (type === 'tabular') {
this.setState({
tabularData: null,
tabularOptions: {},
tabularPromise: this.props.adapters.data.getTabular(),
});
}
};
finishLoadingData() {
if (this.state.tabularPromise) {
this.state.tabularPromise.then(({ data, options }) => {
// Only update the data if the promise resolved before unmounting the component
if (this._isMounted) {
this.setState({
tabularData: data,
tabularOptions: options,
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 (
<InspectorView useFlex={true}>
<EuiEmptyPrompt
title={<h2>No data available</h2>}
body={
<React.Fragment>
<p>The element did not provide any data.</p>
</React.Fragment>
}
/>
</InspectorView>
);
}
renderLoading() {
return (
<InspectorView useFlex={true}>
<EuiFlexGroup
justifyContent="center"
alignItems="center"
>
<EuiFlexItem grow={false}>
<EuiPanel className="eui-textCenter">
<EuiLoadingChart size="m" />
<EuiSpacer size="s" />
<EuiText>
<p>Gathering data</p>
</EuiText>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</InspectorView>
);
}
render() {
if (this.state.tabularPromise) {
return this.renderLoading();
} else if (!this.state.tabularData) {
return this.renderNoData();
}
return (
<InspectorView>
<DataTableFormat
data={this.state.tabularData}
isFormatted={this.state.tabularOptions.returnsFormattedValues}
exportTitle={this.props.title}
/>
</InspectorView>
);
}
}
const DataView = {
title: 'Data',
order: 10,
help: `View the data behind the visualization`,
shouldShow(adapters) {
return Boolean(adapters.data);
},
component: DataViewComponent
};
export { DataView };

View file

@ -0,0 +1,71 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { DataView } from './data_view';
import { DataAdapter } from 'ui/inspector/adapters';
import { mount } from 'enzyme';
jest.mock('./lib/export_csv', () => ({
exportAsCsv: jest.fn(),
}));
describe('Inspector Data View', () => {
it('should only show if data adapter is present', () => {
const adapter = new DataAdapter();
expect(DataView.shouldShow({ data: adapter })).toBe(true);
expect(DataView.shouldShow({})).toBe(false);
});
describe('component', () => {
let adapters;
beforeEach(() => {
adapters = { data: new DataAdapter() };
});
it('should render loading state', () => {
const component = mount(
<DataView.component
title="Test Data"
adapters={adapters}
/>
);
expect(component).toMatchSnapshot();
});
it('should render empty state', async () => {
const component = mount(
<DataView.component
title="Test Data"
adapters={adapters}
/>
);
const tabularLoader = Promise.resolve(null);
adapters.data.setTabularLoader(() => tabularLoader);
await tabularLoader;
// After the loader has resolved we'll still need one update, to "flush" the state changes
component.update();
expect(component).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,127 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
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,
});
};
exportCsv = () => {
exportAsCsv(`${this.props.title}.csv`, this.props.columns, this.props.rows);
}
exportFormattedCsv = () => {
exportAsCsv(`${this.props.title}.csv`, this.props.columns, this.props.rows, item => item.formatted);
};
exportFormattedAsRawCsv = () => {
exportAsCsv(`${this.props.title}.csv`, this.props.columns, this.props.rows, item => item.raw);
};
renderUnformattedDownload() {
return (
<EuiButton
size="s"
onClick={this.exportCsv}
>
Download CSV
</EuiButton>
);
}
renderFormattedDownloads() {
const button = (
<EuiButton
iconType="arrowDown"
iconSide="right"
size="s"
onClick={this.onTogglePopover}
>
Download CSV
</EuiButton>
);
const items = [
<EuiContextMenuItem
key="csv"
onClick={this.exportFormattedCsv}
toolTipContent="Download the data in table format"
toolTipPosition="left"
>
Formatted CSV
</EuiContextMenuItem>,
<EuiContextMenuItem
key="rawCsv"
onClick={this.exportFormattedAsRawCsv}
toolTipContent={`Download the data as provided, for example, dates as timestamps`}
toolTipPosition="left"
>
Raw CSV
</EuiContextMenuItem>
];
return (
<EuiPopover
id="inspectorDownloadData"
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
panelPaddingSize="none"
>
<EuiContextMenuPanel
className="eui-textNoWrap"
items={items}
/>
</EuiPopover>
);
}
render() {
if (!this.props.isFormatted) {
return this.renderUnformattedDownload();
}
return this.renderFormattedDownloads();
}
}
export { DataDownloadOptions };

View file

@ -0,0 +1,63 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _ from 'lodash';
import { saveAs } from '@elastic/filesaver';
import chrome from 'ui/chrome';
function buildCsv(columns, rows, valueFormatter) {
const settings = chrome.getUiSettingsClient();
const csvSeparator = settings.get('csv:separator', ',');
const quoteValues = settings.get('csv:quoteValues', true);
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(valueFormatter ? valueFormatter(row[field]) : 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, valueFormatter) {
const csv = new Blob([buildCsv(columns, rows, valueFormatter)], { type: 'text/plain;charset=utf-8' });
saveAs(csv, filename);
}
export { exportAsCsv };

View file

@ -17,21 +17,10 @@
* under the License.
*/
export default (kibana) => {
return new kibana.Plugin({
id: 'dev_mode',
import { DataView } from './data/data_view';
import { RequestsView } from './requests/requests_view';
isEnabled(config) {
return (
config.get('env.dev') &&
config.get('dev_mode.enabled')
);
},
import { viewRegistry } from 'ui/inspector';
uiExports: {
spyModes: [
'plugins/dev_mode/vis_debug_spy_panel'
]
}
});
};
viewRegistry.register(DataView);
viewRegistry.register(RequestsView);

View file

@ -0,0 +1,23 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './req_details_description';
export * from './req_details_request';
export * from './req_details_response';
export * from './req_details_stats';

View file

@ -0,0 +1,40 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {
EuiText,
} from '@elastic/eui';
function RequestDetailsDescription(props) {
return (
<EuiText className="requests-details__description">
{ props.request.description }
</EuiText>
);
}
RequestDetailsDescription.shouldShow = (request) => !!request.description;
RequestDetailsDescription.propTypes = {
request: PropTypes.object.isRequired,
};
export { RequestDetailsDescription };

View file

@ -0,0 +1,39 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import {
EuiCodeBlock,
} from '@elastic/eui';
function RequestDetailsRequest(props) {
return (
<EuiCodeBlock
language="json"
paddingSize="s"
data-test-subj="inspectorRequestBody"
>
{ JSON.stringify(props.request.json, null, 2) }
</EuiCodeBlock>
);
}
RequestDetailsRequest.shouldShow = (request) => !!request.json;
export { RequestDetailsRequest };

View file

@ -0,0 +1,39 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import {
EuiCodeBlock,
} from '@elastic/eui';
function RequestDetailsResponse(props) {
return (
<EuiCodeBlock
language="json"
paddingSize="s"
data-test-subj="inspectorResponseBody"
>
{ JSON.stringify(props.request.response.json, null, 2) }
</EuiCodeBlock>
);
}
RequestDetailsResponse.shouldShow = (request) => request.response && request.response.json;
export { RequestDetailsResponse };

View file

@ -0,0 +1,81 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiIcon,
EuiIconTip,
EuiTable,
EuiTableBody,
EuiTableRow,
EuiTableRowCell,
} from '@elastic/eui';
class RequestDetailsStats extends Component {
static shouldShow = (request) => !!request.stats && Object.keys(request.stats).length;
renderStatRow = (stat) => {
return [
<EuiTableRow
key={stat.name}
>
<EuiTableRowCell>
<span className="requests-stats__icon">
{ stat.description &&
<EuiIconTip
aria-label="Description"
type="questionInCircle"
color="subdued"
content={stat.description}
/>
}
{ !stat.description &&
<EuiIcon
type="empty"
/>
}
</span>
{stat.name}
</EuiTableRowCell>
<EuiTableRowCell>{stat.value}</EuiTableRowCell>
</EuiTableRow>
];
};
render() {
const { stats } = this.props.request;
const sortedStats = Object.keys(stats).sort().map(name => ({ name, ...stats[name] }));
// TODO: Replace by property once available
return (
<EuiTable style={{ tableLayout: 'auto' }}>
<EuiTableBody>
{ sortedStats.map(this.renderStatRow) }
</EuiTableBody>
</EuiTable>
);
}
}
RequestDetailsStats.propTypes = {
request: PropTypes.object.isRequired,
};
export { RequestDetailsStats };

View file

@ -0,0 +1,108 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
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 {
state = {
availableDetails: [],
selectedDetail: null,
};
static getDerivedStateFromProps(nextProps, prevState) {
const selectedDetail = prevState && prevState.selectedDetail;
const availableDetails = DETAILS.filter(detail =>
!detail.component.shouldShow || detail.component.shouldShow(nextProps.request)
);
// If the previously selected detail is still available we want to stay
// on this tab and not set another selectedDetail.
if (selectedDetail && availableDetails.includes(selectedDetail)) {
return { availableDetails };
}
return {
availableDetails: availableDetails,
selectedDetail: availableDetails[0]
};
}
selectDetailsTab = (detail) => {
if (detail !== this.state.selectedDetail) {
this.setState({
selectedDetail: detail
});
}
};
renderDetailTab = (detail) => {
return (
<EuiTab
key={detail.name}
isSelected={detail === this.state.selectedDetail}
onClick={() => this.selectDetailsTab(detail)}
data-test-subj={`inspectorRequestDetail${detail.name}`}
>
{detail.name}
</EuiTab>
);
}
render() {
if (this.state.availableDetails.length === 0) {
return null;
}
const DetailComponent = this.state.selectedDetail.component;
return (
<div>
<EuiTabs size="s">
{ this.state.availableDetails.map(this.renderDetailTab) }
</EuiTabs>
<DetailComponent
request={this.props.request}
/>
</div>
);
}
}
RequestDetails.propTypes = {
request: PropTypes.object.isRequired,
};
export { RequestDetails };

View file

@ -0,0 +1,168 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiBadge,
EuiButtonEmpty,
EuiContextMenuPanel,
EuiContextMenuItem,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiPopover,
EuiTextColor,
EuiToolTip,
} from '@elastic/eui';
import { RequestStatus } from 'ui/inspector/adapters';
class RequestSelector extends Component {
state = {
isPopoverOpen: false,
};
togglePopover = () => {
this.setState((prevState) => ({
isPopoverOpen: !prevState.isPopoverOpen,
}));
}
closePopover = () => {
this.setState({
isPopoverOpen: false,
});
}
renderRequestDropdownItem = (request, index) => {
const hasFailed = request.status === RequestStatus.ERROR;
const inProgress = request.status === RequestStatus.PENDING;
return (
<EuiContextMenuItem
key={index}
icon={request === this.props.selectedRequest ? 'check' : 'empty'}
onClick={() => {
this.props.onRequestChanged(request);
this.closePopover();
}}
toolTipContent={request.description}
toolTipPosition="left"
>
<EuiTextColor color={hasFailed ? 'danger' : 'default'}>
{request.name}
{ hasFailed && ' (failed)' }
{ inProgress &&
<EuiLoadingSpinner
size="s"
aria-label="Request in progress"
className="inspector-request-chooser__menu-spinner"
/>
}
</EuiTextColor>
</EuiContextMenuItem>
);
}
renderRequestDropdown() {
const button = (
<EuiButtonEmpty
iconType="arrowDown"
iconSide="right"
size="s"
className="inspector-request-chooser__request-title"
onClick={this.togglePopover}
>
{this.props.selectedRequest.name}
</EuiButtonEmpty>
);
return (
<EuiPopover
id="inspectorRequestChooser"
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
className="inspector-request-chooser__menu-panel"
items={this.props.requests.map(this.renderRequestDropdownItem)}
/>
</EuiPopover>
);
}
render() {
const { selectedRequest, requests } = this.props;
return (
<EuiFlexGroup
alignItems="center"
gutterSize="xs"
>
<EuiFlexItem
grow={false}
className="inspector-request-chooser__request-title"
>
Request:
</EuiFlexItem>
<EuiFlexItem grow={true}>
{requests.length <= 1 &&
<div className="inspector-request-chooser__single-request">
{selectedRequest.name}
</div>
}
{requests.length > 1 && this.renderRequestDropdown()}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{ selectedRequest.status !== RequestStatus.PENDING &&
<EuiToolTip
position="left"
title={selectedRequest.status === RequestStatus.OK ? 'Request succeeded' : 'Request failed'}
content="The total time the request took."
>
<EuiBadge
color={selectedRequest.status === RequestStatus.OK ? 'secondary' : 'danger'}
iconType={selectedRequest.status === RequestStatus.OK ? 'check' : 'cross'}
>
{selectedRequest.time}ms
</EuiBadge>
</EuiToolTip>
}
{ selectedRequest.status === RequestStatus.PENDING &&
<EuiLoadingSpinner
size="m"
aria-label="Request in progress"
/>
}
</EuiFlexItem>
</EuiFlexGroup>
);
}
}
RequestSelector.propTypes = {
requests: PropTypes.array.isRequired,
selectedRequest: PropTypes.object.isRequired,
};
export { RequestSelector };

View file

@ -0,0 +1,27 @@
.requests-details__description {
padding: 16px;
}
.requests-stats__icon {
margin-right: 8px;
}
.inspector-request-chooser__menu-panel {
min-width: 300px;
}
.inspector-request-chooser__request-title {
font-size: 1.2em;
}
.inspector-request-chooser__single-request {
.inspector-request-chooser__request-title();
height: 30px; // height of EuiEmptyButton small
padding: 0 8px;
display: flex;
align-items: center;
}
.inspector-request-chooser__menu-spinner {
margin-left: 8px;
}

View file

@ -0,0 +1,146 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiEmptyPrompt,
EuiSpacer,
EuiText,
EuiTextColor,
} from '@elastic/eui';
import { InspectorView } from 'ui/inspector';
import { RequestStatus } from 'ui/inspector/adapters';
import { RequestSelector } from './request_selector';
import { RequestDetails } from './request_details';
import './requests_inspector.less';
class RequestsViewComponent extends Component {
constructor(props) {
super(props);
props.adapters.requests.on('change', this._onRequestsChange);
const requests = props.adapters.requests.getRequests();
this.state = {
requests: requests,
request: requests.length ? requests[0] : null
};
}
_onRequestsChange = () => {
const requests = this.props.adapters.requests.getRequests();
const newState = { requests };
if (!requests.includes(this.state.request)) {
newState.request = requests.length ? requests[0] : null;
}
this.setState(newState);
}
selectRequest = (request) => {
if (request !== this.state.request) {
this.setState({ request });
}
}
componentWillUnmount() {
this.props.adapters.requests.removeListener('change', this._onRequestsChange);
}
renderEmptyRequests() {
return (
<InspectorView useFlex={true}>
<EuiEmptyPrompt
title={<h2>No requests logged</h2>}
body={
<React.Fragment>
<p>The element hasn&apos;t logged any requests (yet).</p>
<p>
This usually means that there was no need to fetch any data or
that the element has not yet started fetching data.
</p>
</React.Fragment>
}
/>
</InspectorView>
);
}
render() {
if (!this.state.requests || !this.state.requests.length) {
return this.renderEmptyRequests();
}
const failedCount = this.state.requests.filter(
req => req.status === RequestStatus.ERROR
).length;
return (
<InspectorView>
<EuiText size="xs">
<p role="status" aria-live="polite" aria-atomic="true">
{this.state.requests.length}
{this.state.requests.length !== 1 ? ' requests were' : ' request was'} made
{failedCount > 0 &&
<React.Fragment>
, {' '}
<EuiTextColor
color="danger"
>
{failedCount} had a failure
</EuiTextColor>
</React.Fragment>
}
</p>
</EuiText>
<EuiSpacer size="xs"/>
<RequestSelector
requests={this.state.requests}
selectedRequest={this.state.request}
onRequestChanged={this.selectRequest}
/>
<EuiSpacer size="m" />
{ this.state.request &&
<RequestDetails
request={this.state.request}
/>
}
</InspectorView>
);
}
}
RequestsViewComponent.propTypes = {
adapters: PropTypes.object.isRequired,
};
const RequestsView = {
title: 'Requests',
order: 20,
help: `View the requests that collected the data`,
shouldShow(adapters) {
return Boolean(adapters.requests);
},
component: RequestsViewComponent
};
export { RequestsView };

View file

@ -0,0 +1,68 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import {
EuiIcon,
} from '@elastic/eui';
import { Inspector } from 'ui/inspector';
import { DashboardPanelAction } from 'ui/dashboard_panel_actions';
/**
* Returns the dashboard panel action for opening an inspector for a specific panel.
* This will check if the embeddable inside the panel actually exposes inspector adapters
* via its embeddable.getInspectorAdapters() method. If so - and if an inspector
* could be shown for those adapters - the inspector icon will be visible.
* @return {DashboardPanelAction}
*/
export function getInspectorPanelAction({ closeContextMenu, panelTitle }) {
return new DashboardPanelAction(
{
id: 'openInspector',
displayName: 'Inspector',
parentPanelId: 'mainMenu',
},
{
icon: <EuiIcon type="inspect" />,
onClick: ({ embeddable }) => {
closeContextMenu();
const session = Inspector.open(embeddable.getInspectorAdapters(), {
title: panelTitle,
});
// Overwrite the embeddables.destroy() function to close the inspector
// before calling the original destroy method
const originalDestroy = embeddable.destroy;
embeddable.destroy = () => {
session.close();
if (originalDestroy) {
originalDestroy.call(embeddable);
}
};
// In case the inspector gets closed (otherwise), restore the original destroy function
session.on('closed', () => {
embeddable.destroy = originalDestroy;
});
},
isVisible: ({ embeddable }) => (
embeddable && Inspector.isAvailable(embeddable.getInspectorAdapters())
),
});
}

View file

@ -22,3 +22,4 @@ export { getRemovePanelAction } from './get_remove_panel_action';
export { buildEuiContextMenuPanels } from './build_context_menu';
export { getCustomizePanelAction } from './get_customize_panel_action';
export { getToggleExpandPanelAction } from './get_toggle_expand_panel_action';
export { getInspectorPanelAction } from './get_inspector_panel_action';

View file

@ -25,6 +25,7 @@ import { PanelOptionsMenu } from './panel_options_menu';
import {
buildEuiContextMenuPanels,
getEditPanelAction,
getInspectorPanelAction,
getRemovePanelAction,
getCustomizePanelAction,
getToggleExpandPanelAction,
@ -124,6 +125,10 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
});
const actions = [
getInspectorPanelAction({
closeContextMenu: closeMyContextMenuPanel,
panelTitle,
}),
getEditPanelAction(),
getCustomizePanelAction({
onResetPanelTitle,

View file

@ -175,10 +175,6 @@
}
}
.visualize-show-spy {
visibility: visible;
}
.panel-heading {
cursor: move;
}
@ -316,10 +312,6 @@ dashboard-viewport-provider {
}
}
.visualize-show-spy {
visibility: hidden;
}
/**
* 1. Use opacity to make this element accessible to screen readers and keyboard.
* 2. Show on focus to enable keyboard accessibility.
@ -351,9 +343,6 @@ dashboard-viewport-provider {
.panel-heading-floater {
opacity: 1;
}
.visualize-show-spy {
visibility: visible;
}
.viewModeOpenContextMenuIcon {
opacity: 1;
}

View file

@ -134,7 +134,6 @@
vis="vis"
ui-state="uiState"
vis-data="visData"
show-spy-panel="true"
search-source="searchSource"
style="height: 200px"
>

View file

@ -31,7 +31,6 @@ import 'uiExports/visResponseHandlers';
import 'uiExports/visRequestHandlers';
import 'uiExports/visEditorTypes';
import 'uiExports/savedObjectTypes';
import 'uiExports/spyModes';
import 'uiExports/fieldFormats';
import 'uiExports/fieldFormatEditors';
import 'uiExports/navbarExtensions';
@ -40,6 +39,7 @@ import 'uiExports/managementSections';
import 'uiExports/devTools';
import 'uiExports/docViews';
import 'uiExports/embeddableFactories';
import 'uiExports/inspectorViews';
import 'ui/autoload/all';
import './home';

View file

@ -71,7 +71,6 @@
ui-state="uiState"
time-range="timeRange"
editor-mode="chrome.getVisible()"
show-spy-panel="chrome.getVisible()"
>
</visualize>

View file

@ -130,6 +130,21 @@ 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',
testId: 'openInspectorButton',
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',

View file

@ -19,7 +19,6 @@
import { PersistedState } from 'ui/persisted_state';
import { Embeddable } from 'ui/embeddable';
import chrome from 'ui/chrome';
import _ from 'lodash';
export class VisualizeEmbeddable extends Embeddable {
@ -46,6 +45,10 @@ export class VisualizeEmbeddable extends Embeddable {
this._onEmbeddableStateChanged(this.getEmbeddableState());
};
getInspectorAdapters() {
return this.savedVisualization.vis.API.inspectorAdapters;
}
getEmbeddableState() {
return {
customization: this.customization,
@ -146,7 +149,6 @@ export class VisualizeEmbeddable extends Embeddable {
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,

View file

@ -1,4 +0,0 @@
{
"name": "spy_modes",
"version": "kibana"
}

View file

@ -1,45 +0,0 @@
<div ng-if="!req.stopped" class="visualize-spy-loading">
<div class="visualize-spy-loading-text">
Request in progress
&nbsp;
<div class="spinner"></div>
</div>
</div>
<div ng-if="req.stopped && req.success === false" ng-init="console.log(req)" class="alert alert-danger">
<i class="fa fa-danger"></i> Request Failed
</div>
<div ng-if="mode === 'request'">
<h3 class="euiTitle euiTitle--small">
Elasticsearch request body
</h3>
<div
data-test-subj="visualizationEsRequestBody"
class="euiCodeBlock euiCodeBlock--light euiCodeBlock--fontSmall euiCodeBlock--paddingSmall"
>
<code class="euiCodeBlock__code">
<pre class="euiCodeBlock__pre">{{req.fetchParams.body | json}}</pre>
</code>
</div>
</div>
<div ng-if="mode === 'response'">
<h3 class="euiTitle euiTitle--small">
Elasticsearch response body
</h3>
<div class="euiCodeBlock euiCodeBlock--light euiCodeBlock--fontSmall euiCodeBlock--paddingSmall">
<code class="euiCodeBlock__code">
<pre class="euiCodeBlock__pre">{{req.resp | json}}</pre>
</code>
</div>
</div>
<div ng-if="mode === 'stats'">
<table class="table">
<tr ng-repeat="pair in stats">
<td>{{pair[0]}}</td>
<td>{{pair[1]}}</td>
</tr>
</table>
</div>

View file

@ -1,84 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import reqRespStatsHTML from './req_resp_stats_spy_mode.html';
import { SpyModesRegistryProvider } from 'ui/registry/spy_modes';
const linkReqRespStats = function (mode, $scope) {
$scope.mode = mode;
$scope.$bind('req', 'searchSource.history[searchSource.history.length - 1]');
$scope.$watchMulti([
'req',
'req.started',
'req.stopped',
'searchSource'
], function () {
if (!$scope.searchSource || !$scope.req) return;
const req = $scope.req;
const resp = $scope.req.resp;
const stats = $scope.stats = [];
if (resp && resp.took != null) stats.push(['Query Duration', resp.took + 'ms']);
if (req && req.ms != null) stats.push(['Request Duration', req.ms + 'ms']);
if (resp && resp.hits) stats.push(['Hits', resp.hits.total]);
if (req.fetchParams && req.fetchParams.index) {
if (req.fetchParams.index.title) stats.push(['Index', req.fetchParams.index.title]);
if (req.fetchParams.index.type) stats.push(['Type', req.fetchParams.index.type]);
if (req.fetchParams.index.id) stats.push(['Id', req.fetchParams.index.id]);
}
});
};
function shouldShowSpyMode(vis) {
return vis.type.requestHandler === 'courier' && vis.type.requiresSearch;
}
SpyModesRegistryProvider
.register(function () {
return {
name: 'request',
display: 'Request',
order: 2,
template: reqRespStatsHTML,
showMode: shouldShowSpyMode,
link: linkReqRespStats.bind(null, 'request')
};
})
.register(function () {
return {
name: 'response',
display: 'Response',
order: 3,
template: reqRespStatsHTML,
showMode: shouldShowSpyMode,
link: linkReqRespStats.bind(null, 'response')
};
})
.register(function () {
return {
name: 'stats',
display: 'Statistics',
order: 4,
template: reqRespStatsHTML,
showMode: shouldShowSpyMode,
link: linkReqRespStats.bind(null, 'stats')
};
});

View file

@ -1,5 +0,0 @@
<kbn-agg-table
table="table"
export-title="vis.title"
per-page="rowsPerPage">
</kbn-agg-table>

View file

@ -1,56 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// import 'ui/agg_table';
import { tabifyAggResponse } from 'ui/agg_response/tabify/tabify';
import tableSpyModeTemplate from './table_spy_mode.html';
import { SpyModesRegistryProvider } from 'ui/registry/spy_modes';
function VisSpyTableProvider(Notifier, $filter, $rootScope) {
const PER_PAGE_DEFAULT = 10;
return {
name: 'table',
display: 'Table',
order: 1,
template: tableSpyModeTemplate,
showMode: vis => vis.type.requestHandler === 'courier' && vis.type.requiresSearch,
link: function tableLinkFn($scope) {
$rootScope.$watchMulti.call($scope, [
'vis',
'searchSource.rawResponse'
], function () {
if (!$scope.vis || !$scope.searchSource.rawResponse) {
$scope.table = null;
} else {
$scope.rowsPerPage = PER_PAGE_DEFAULT;
$scope.table = tabifyAggResponse($scope.vis.getAggConfig().getResponseAggs(), $scope.searchSource.rawResponse, {
canSplit: false,
asAggConfigResults: true,
partialRows: true,
isHierarchical: $scope.vis.isHierarchical()
});
}
});
}
};
}
SpyModesRegistryProvider.register(VisSpyTableProvider);

View file

@ -66,6 +66,6 @@ export default function TimelionVisProvider(Private) {
showIndexSelection: false,
showQueryBar: false,
showFilterBar: false,
}
},
});
}

View file

@ -32,7 +32,6 @@ export const CopySourceTask = {
'!src/**/{__tests__,__snapshots__}/**',
'!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/**',

View file

@ -25,6 +25,8 @@ import { createFilterTerms } from './create_filter/terms';
import orderAggTemplate from '../controls/order_agg.html';
import orderAndSizeTemplate from '../controls/order_and_size.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';
@ -94,7 +96,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', {
description: `This request counts the number of documents that fall
outside the criterion of the data buckets.`
});
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) {

View file

@ -0,0 +1,74 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* 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'] = {
value: index.title,
description: 'The index pattern that connected to the Elasticsearch indices.',
};
stats ['Index pattern ID'] = {
value: index.id,
description: 'The ID 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 to process the query.
Does not include the time to send the request or parse it in the browser.`,
};
}
if (resp && resp.hits) {
stats.Hits = {
value: `${resp.hits.total}`,
description: 'The number of documents that match the query.',
};
}
if (lastRequest && (lastRequest.ms === 0 || lastRequest.ms)) {
stats['Request time'] = {
value: `${lastRequest.ms}ms`,
description: `The time of the request from the browser to Elasticsearch and back.
Does not include the time the requested waited in the queue.`
};
}
return stats;
}
export { getRequestInspectorStats, getResponseInspectorStats };

View file

@ -18,6 +18,7 @@
*/
import * as PropTypes from 'prop-types';
import { Adapters } from 'ui/inspector';
import { ContainerState } from './types';
// TODO: we'll be able to get rid of this shape once all of dashboard is typescriptified too.
@ -89,6 +90,15 @@ export abstract class Embeddable {
containerState: ContainerState
): void;
/**
* An embeddable can return inspector adapters if it want the inspector to be
* available via the context menu of that panel.
* @return Inspector adapters that will be used to open an inspector for.
*/
public getInspectorAdapters(): Adapters | undefined {
return undefined;
}
public destroy(): void {
return;
}

View file

@ -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 tied 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 (
<InspectorView>
{ /* Always use InspectorView as the wrapping element! */ }
</InspectorView>
);
}
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: {
someAdapter: 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.

View file

@ -0,0 +1,57 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EventEmitter } from 'events';
// TODO: add a more specific TabularData type.
type TabularData = any;
type TabularCallback = () => TabularData | Promise<TabularData>;
interface TabularHolder {
data: TabularData | null;
options: TabularLoaderOptions;
}
interface TabularLoaderOptions {
returnsFormattedValues?: boolean;
}
class DataAdapter extends EventEmitter {
private tabular?: TabularCallback;
private tabularOptions?: TabularLoaderOptions;
public setTabularLoader(
callback: TabularCallback,
options: TabularLoaderOptions = {}
): void {
this.tabular = callback;
this.tabularOptions = options;
this.emit('change', 'tabular');
}
public getTabular(): Promise<TabularHolder> {
if (!this.tabular || !this.tabularOptions) {
return Promise.resolve({ data: null, options: {} });
}
const options = this.tabularOptions;
return Promise.resolve(this.tabular()).then(data => ({ data, options }));
}
}
export { DataAdapter };

View file

@ -0,0 +1,67 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { DataAdapter } from './data_adapter';
describe('DataAdapter', () => {
let adapter: DataAdapter;
beforeEach(() => {
adapter = new DataAdapter();
});
describe('getTabular()', () => {
it('should return a null promise when called before initialized', () => {
expect(adapter.getTabular()).resolves.toEqual({
data: null,
options: {},
});
});
it('should call the provided callback and resolve with its value', async () => {
const spy = jest.fn(() => 'foo');
adapter.setTabularLoader(spy);
expect(spy).not.toBeCalled();
const result = await adapter.getTabular();
expect(spy).toBeCalled();
expect(result.data).toBe('foo');
});
it('should pass through options specified via setTabularLoader', async () => {
adapter.setTabularLoader(() => 'foo', { returnsFormattedValues: true });
const result = await adapter.getTabular();
expect(result.options).toEqual({ returnsFormattedValues: true });
});
it('should return options set when starting loading data', async () => {
adapter.setTabularLoader(() => 'foo', { returnsFormattedValues: true });
const waitForResult = adapter.getTabular();
adapter.setTabularLoader(() => 'bar', { returnsFormattedValues: false });
const result = await waitForResult;
expect(result.options).toEqual({ returnsFormattedValues: true });
});
});
it('should emit a "tabular" event when a new tabular loader is specified', () => {
const spy = jest.fn();
adapter.once('change', spy);
adapter.setTabularLoader(() => 42);
expect(spy).toBeCalled();
});
});

View file

@ -17,10 +17,8 @@
* under the License.
*/
import { uiRegistry } from './_registry';
class FormattedData {
constructor(readonly raw: any, readonly formatted: any) {}
}
export const SpyModesRegistryProvider = uiRegistry({
name: 'spyModes',
index: ['name'],
order: ['order']
});
export { FormattedData };

View file

@ -0,0 +1,21 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { FormattedData } from './formatted_data';
export { DataAdapter } from './data_adapter';

View file

@ -0,0 +1,21 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { DataAdapter, FormattedData } from './data';
export { RequestAdapter, RequestStatus } from './request';

View file

@ -0,0 +1,22 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { RequestStatus } from './types';
export { RequestAdapter } from './request_adapter';

View file

@ -0,0 +1,81 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { RequestAdapter } from './request_adapter';
import { Request } from './types';
describe('RequestAdapter', () => {
let adapter: RequestAdapter;
beforeEach(() => {
adapter = new RequestAdapter();
});
describe('getRequests()', () => {
function requestNames(requests: Request[]) {
return requests.map(req => req.name);
}
it('should return all started requests', () => {
adapter.start('req1');
adapter.start('req2');
expect(adapter.getRequests().length).toBe(2);
expect(requestNames(adapter.getRequests())).toEqual(['req1', 'req2']);
});
it('should reset when calling reset()', () => {
adapter.start('req1');
expect(adapter.getRequests().length).toBe(1);
adapter.reset();
expect(adapter.getRequests()).toEqual([]);
});
it('should not return requests started before reset, but finished after it', () => {
const req = adapter.start('req1');
expect(adapter.getRequests().length).toBe(1);
adapter.reset();
req.ok({ json: {} });
expect(adapter.getRequests()).toEqual([]);
});
});
describe('change events', () => {
it('should emit it when starting a new request', () => {
const spy = jest.fn();
adapter.once('change', spy);
expect(spy).not.toBeCalled();
adapter.start('request');
expect(spy).toBeCalled();
});
it('should emit it when updating the request', () => {
const spy = jest.fn();
adapter.on('change', spy);
expect(spy).not.toBeCalled();
const req = adapter.start('request');
expect(spy).toHaveBeenCalledTimes(1);
req.json({ my: 'request' });
expect(spy).toHaveBeenCalledTimes(2);
req.stats({ foo: 42, bar: 'test' });
expect(spy).toHaveBeenCalledTimes(3);
req.ok({ json: {} });
expect(spy).toHaveBeenCalledTimes(4);
});
});
});

View file

@ -0,0 +1,70 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EventEmitter } from 'events';
import { RequestResponder } from './request_responder';
import { Request, RequestParams, RequestStatus } from './types';
/**
* 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 {
private requests: Request[] = [];
/**
* 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.
*/
public start(name: string, params: RequestParams = {}): RequestResponder {
const req: Request = {
...params,
name,
startTime: Date.now(),
status: RequestStatus.PENDING,
};
this.requests.push(req);
this._onChange();
return new RequestResponder(req, () => this._onChange());
}
public reset(): void {
this.requests = [];
this._onChange();
}
public getRequests(): Request[] {
return this.requests;
}
private _onChange(): void {
this.emit('change');
}
}
export { RequestAdapter };

View file

@ -0,0 +1,64 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Request, RequestStatistics, RequestStatus, Response } from './types';
/**
* 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}.
*/
export class RequestResponder {
private readonly request: Request;
private readonly onChange: () => void;
constructor(request: Request, onChange: () => void) {
this.request = request;
this.onChange = onChange;
}
public json(reqJson: object): RequestResponder {
this.request.json = reqJson;
this.onChange();
return this;
}
public stats(stats: RequestStatistics): RequestResponder {
this.request.stats = {
...(this.request.stats || {}),
...stats,
};
this.onChange();
return this;
}
public finish(status: RequestStatus, response: Response): void {
this.request.time = Date.now() - this.request.startTime;
this.request.status = status;
this.request.response = response;
this.onChange();
}
public ok(response: Response): void {
this.finish(RequestStatus.OK, response);
}
public error(response: Response): void {
this.finish(RequestStatus.ERROR, response);
}
}

View file

@ -0,0 +1,58 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* The status a request can have.
*/
export enum RequestStatus {
/**
* The request hasn't finished yet.
*/
PENDING,
/**
* The request has successfully finished.
*/
OK,
/**
* The request failed.
*/
ERROR,
}
export interface Request extends RequestParams {
name: string;
json?: object;
response?: Response;
startTime: number;
stats?: RequestStatistics;
status: RequestStatus;
time?: number;
}
export interface RequestParams {
description?: string;
}
export interface RequestStatistics {
[key: string]: any;
}
export interface Response {
json?: object;
}

View file

@ -0,0 +1,26 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { InspectorView } from './ui';
export { Inspector } from './inspector';
export { viewRegistry } from './view_registry';
export { Adapters } from './types';

View file

@ -0,0 +1,109 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Inspector } from './inspector';
jest.mock('./view_registry', () => ({
viewRegistry: {
getVisible: jest.fn(),
},
}));
jest.mock('./ui/inspector_panel', () => ({
InspectorPanel: () => 'InspectorPanel',
}));
import { viewRegistry } from './view_registry';
function setViews(views) {
viewRegistry.getVisible.mockImplementation(() => views);
}
describe('Inspector', () => {
describe('isAvailable()', () => {
it('should return false if no view would be available', () => {
setViews([]);
expect(Inspector.isAvailable({})).toBe(false);
});
it('should return true if views would be available', () => {
setViews([{}]);
expect(Inspector.isAvailable({})).toBe(true);
});
});
describe('open()', () => {
it('should throw an error if no views available', () => {
setViews([]);
expect(() => Inspector.open({})).toThrow();
});
describe('return value', () => {
beforeEach(() => {
setViews([{}]);
});
it('should be an object with a close function', () => {
const session = Inspector.open({});
expect(typeof session.close).toBe('function');
});
it('should emit the "closed" event if another inspector opens', () => {
const session = Inspector.open({});
const spy = jest.fn();
session.on('closed', spy);
Inspector.open({});
expect(spy).toHaveBeenCalled();
});
it('should emit the "closed" event if you call close', () => {
const session = Inspector.open({});
const spy = jest.fn();
session.on('closed', spy);
session.close();
expect(spy).toHaveBeenCalled();
});
it('can be bound to an angular scope', () => {
const session = Inspector.open({});
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 = Inspector.open({});
const unwatchSpy = jest.fn();
const scope = {
$on: jest.fn(() => unwatchSpy)
};
session.bindToAngularScope(scope);
session.close();
expect(unwatchSpy).toHaveBeenCalled();
});
});
});
});

View file

@ -0,0 +1,151 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EventEmitter } from 'events';
import React from 'react';
import ReactDOM from 'react-dom';
import { Adapters } from './types';
import { InspectorPanel } from './ui/inspector_panel';
import { viewRegistry } from './view_registry';
let activeSession: InspectorSession | null = 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.
*/
public bindToAngularScope(scope: ng.IScope): void {
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.
*/
public close(): void {
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 `open`.
* @returns {boolean} True, if a call to `open` with the same adapters
* would have shown the inspector panel, false otherwise.
*/
function isAvailable(adapters?: Adapters): boolean {
return viewRegistry.getVisible(adapters).length > 0;
}
/**
* Options that can be specified when opening the inspector.
* @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.
*/
interface InspectorOptions {
title?: string;
}
/**
* 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 open(
adapters: Adapters,
options: InspectorOptions = {}
): InspectorSession {
// 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 Inspector.isAvailable() with the same adapters before to check
if an inspector can be shown.`);
}
const container = getOrCreateContainerElement();
const session = (activeSession = new InspectorSession());
ReactDOM.render(
<InspectorPanel
views={views}
adapters={adapters}
onClose={() => session.close()}
title={options.title}
/>,
container
);
return session;
}
const Inspector = {
isAvailable,
open,
};
export { Inspector };

View file

@ -0,0 +1,63 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* The interface that the adapters used to open spy panels have to fullfill.
*/
export interface Adapters {
[key: string]: any;
}
/**
* The props interface that a custom inspector view component, that will be passed
* to {@link InspectorViewDescription#component}, must use.
*/
export interface InspectorViewProps {
/**
* The adapters thta has been used to open the inspector.
*/
adapters: Adapters;
/**
* The title that the inspector is currently using e.g. a visualization name.
*/
title: string;
}
/**
* An object describing an inspector view.
* @typedef {object} InspectorViewDescription
* @property {string} title - The title that will be used to present that view.
* @property {string} icon - An icon name to present this view. Must match an EUI icon.
* @property {React.ComponentType<InspectorViewProps>} 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.
*/
export interface InspectorViewDescription {
component: React.ComponentType<InspectorViewProps>;
help?: string;
order?: number;
shouldShow?: (adapters: Adapters) => boolean;
title: string;
}

View file

@ -0,0 +1,339 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InspectorPanel should render as expected 1`] = `
<InspectorPanel
adapters={
Object {
"bardapter": Object {},
"foodapter": Object {
"foo": [Function],
},
}
}
onClose={[Function]}
title="Inspector"
views={
Array [
Object {
"component": [Function],
"order": 200,
"title": "View 1",
},
Object {
"component": [Function],
"order": 100,
"shouldShow": [Function],
"title": "Foo View",
},
Object {
"component": [Function],
"order": 200,
"shouldShow": [Function],
"title": "Never",
},
]
}
>
<EuiFlyout
data-test-subj="inspectorPanel"
hideCloseButton={false}
onClose={[Function]}
ownFocus={false}
size="m"
>
<span>
<FocusTrap
_createFocusTrap={[Function]}
active={true}
focusTrapOptions={
Object {
"clickOutsideDeactivates": true,
"fallbackFocus": [Function],
}
}
paused={false}
tag="div"
>
<div>
<div
className="euiFlyout euiFlyout--medium"
data-test-subj="inspectorPanel"
onKeyDown={[Function]}
role="dialog"
tabIndex={0}
>
<EuiButtonIcon
aria-label="Closes this dialog"
className="euiFlyout__closeButton"
color="text"
iconType="cross"
onClick={[Function]}
type="button"
>
<button
aria-label="Closes this dialog"
className="euiButtonIcon euiButtonIcon--text euiFlyout__closeButton"
onClick={[Function]}
type="button"
>
<EuiIcon
aria-hidden="true"
className="euiButtonIcon__icon"
size="m"
type="cross"
>
<cross
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={
Object {
"fill": undefined,
}
}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={
Object {
"fill": undefined,
}
}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<path
d="M7.293 8l-4.147 4.146a.5.5 0 0 0 .708.708L8 8.707l4.146 4.147a.5.5 0 0 0 .708-.708L8.707 8l4.147-4.146a.5.5 0 0 0-.708-.708L8 7.293 3.854 3.146a.5.5 0 1 0-.708.708L7.293 8z"
id="cross-a"
/>
</defs>
<use
fillRule="nonzero"
xlinkHref="#cross-a"
/>
</svg>
</cross>
</EuiIcon>
</button>
</EuiButtonIcon>
<EuiFlyoutHeader
hasBorder={true}
>
<div
className="euiFlyoutHeader euiFlyoutHeader--hasBorder"
>
<EuiFlexGroup
alignItems="center"
component="div"
direction="row"
gutterSize="l"
justifyContent="spaceBetween"
responsive={true}
wrap={false}
>
<div
className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<EuiFlexItem
component="div"
grow={true}
>
<div
className="euiFlexItem"
>
<EuiTitle
size="s"
>
<h1
className="euiTitle euiTitle--small"
>
Inspector
</h1>
</EuiTitle>
</div>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<InspectorViewChooser
onViewSelected={[Function]}
selectedView={
Object {
"component": [Function],
"order": 200,
"title": "View 1",
}
}
views={
Array [
Object {
"component": [Function],
"order": 200,
"title": "View 1",
},
Object {
"component": [Function],
"order": 100,
"shouldShow": [Function],
"title": "Foo View",
},
Object {
"component": [Function],
"order": 200,
"shouldShow": [Function],
"title": "Never",
},
]
}
>
<EuiPopover
anchorPosition="downRight"
button={
<EuiButtonEmpty
color="primary"
data-test-subj="inspectorViewChooser"
iconSide="right"
iconType="arrowDown"
onClick={[Function]}
size="s"
type="button"
>
View:
View 1
</EuiButtonEmpty>
}
closePopover={[Function]}
id="inspectorViewChooser"
isOpen={false}
ownFocus={true}
panelPaddingSize="none"
>
<EuiOutsideClickDetector
onOutsideClick={[Function]}
>
<div
className="euiPopover euiPopover--anchorDownRight"
id="inspectorViewChooser"
onKeyDown={[Function]}
>
<EuiButtonEmpty
color="primary"
data-test-subj="inspectorViewChooser"
iconSide="right"
iconType="arrowDown"
onClick={[Function]}
size="s"
type="button"
>
<button
className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--small euiButtonEmpty--iconRight"
data-test-subj="inspectorViewChooser"
onClick={[Function]}
type="button"
>
<span
className="euiButtonEmpty__content"
>
<EuiIcon
aria-hidden="true"
className="euiButtonEmpty__icon"
size="m"
type="arrowDown"
>
<arrowDown
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonEmpty__icon"
focusable="false"
height="16"
style={
Object {
"fill": undefined,
}
}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonEmpty__icon"
focusable="false"
height="16"
style={
Object {
"fill": undefined,
}
}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<path
d="M13.069 5.157L8.384 9.768a.546.546 0 0 1-.768 0L2.93 5.158a.552.552 0 0 0-.771 0 .53.53 0 0 0 0 .759l4.684 4.61c.641.631 1.672.63 2.312 0l4.684-4.61a.53.53 0 0 0 0-.76.552.552 0 0 0-.771 0z"
id="arrow_down-a"
/>
</defs>
<use
fillRule="nonzero"
xlinkHref="#arrow_down-a"
/>
</svg>
</arrowDown>
</EuiIcon>
<span>
View:
View 1
</span>
</span>
</button>
</EuiButtonEmpty>
</div>
</EuiOutsideClickDetector>
</EuiPopover>
</InspectorViewChooser>
</div>
</EuiFlexItem>
</div>
</EuiFlexGroup>
</div>
</EuiFlyoutHeader>
<component
adapters={
Object {
"bardapter": Object {},
"foodapter": Object {
"foo": [Function],
},
}
}
title="Inspector"
>
<h1>
View 1
</h1>
</component>
</div>
</div>
</FocusTrap>
</span>
</EuiFlyout>
</InspectorPanel>
`;

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { InspectorView } from './inspector_view';

View file

@ -0,0 +1,7 @@
.inspector-view-chooser__icon {
margin-right: 8px;
}
.inspector-view__flex {
display: flex;
}

View file

@ -16,18 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ComponentClass } from 'react';
import visDebugSpyPanelTemplate from './vis_debug_spy_panel.html';
import { SpyModesRegistryProvider } from 'ui/registry/spy_modes';
import { Adapters, InspectorViewDescription } from '../types';
function VisDetailsSpyProvider() {
return {
name: 'debug',
display: 'Debug',
template: visDebugSpyPanelTemplate,
order: 5
};
interface InspectorPanelProps {
adapters: Adapters;
onClose: () => void;
title?: string;
views: InspectorViewDescription[];
}
// register the spy mode or it won't show up in the spys
SpyModesRegistryProvider.register(VisDetailsSpyProvider);
export const InspectorPanel: ComponentClass<InspectorPanelProps>;

View file

@ -0,0 +1,132 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutHeader,
EuiTitle,
} from '@elastic/eui';
import { InspectorViewChooser } from './inspector_view_chooser';
import './inspector.less';
function hasAdaptersChanged(oldAdapters, newAdapters) {
return Object.keys(oldAdapters).length !== Object.keys(newAdapters).length
|| Object.keys(oldAdapters).some(key => oldAdapters[key] !== newAdapters[key]);
}
class InspectorPanel extends Component {
constructor(props) {
super(props);
this.state = {
isHelpPopoverOpen: false,
selectedView: props.views[0],
views: props.views,
// Clone adapters array so we can validate that this prop never change
adapters: { ...props.adapters },
};
}
static getDerivedStateFromProps(nextProps, prevState) {
if (hasAdaptersChanged(prevState.adapters, nextProps.adapters)) {
throw new Error('Adapters are not allowed to be changed on an open InspectorPanel.');
}
const selectedViewMustChange = nextProps.views !== prevState.views
&& !nextProps.views.includes(prevState.selectedView);
return {
views: nextProps.views,
selectedView: selectedViewMustChange ? nextProps.views[0] : prevState.selectedView,
};
}
onViewSelected = (view) => {
if (view !== this.state.selectedView) {
this.setState({
selectedView: view
});
}
};
renderSelectedPanel() {
return (
<this.state.selectedView.component
adapters={this.props.adapters}
title={this.props.title}
/>
);
}
render() {
const { views, onClose, title } = this.props;
const { selectedView } = this.state;
return (
<EuiFlyout
onClose={onClose}
data-test-subj="inspectorPanel"
>
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
>
<EuiFlexItem grow={true}>
<EuiTitle size="s">
<h1>{ title }</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<InspectorViewChooser
views={views}
onViewSelected={this.onViewSelected}
selectedView={selectedView}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
{ this.renderSelectedPanel() }
</EuiFlyout>
);
}
}
InspectorPanel.defaultProps = {
title: 'Inspector',
};
InspectorPanel.propTypes = {
adapters: PropTypes.object.isRequired,
views: (props, propName, componentName) => {
if (!Array.isArray(props[propName]) || props[propName].length < 1) {
throw new Error(
`${propName} prop must be an array of at least one element in ${componentName}.`
);
}
},
onClose: PropTypes.func.isRequired,
title: PropTypes.string,
};
export { InspectorPanel };

View file

@ -0,0 +1,83 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { InspectorPanel } from './inspector_panel';
describe('InspectorPanel', () => {
let adapters;
let views;
beforeEach(() => {
adapters = {
foodapter: {
foo() { return 42; }
},
bardapter: {
}
};
views = [
{
title: 'View 1',
order: 200,
component: () => (<h1>View 1</h1>),
}, {
title: 'Foo View',
order: 100,
component: () => (<h1>Foo view</h1>),
shouldShow(adapters) {
return adapters.foodapter;
}
}, {
title: 'Never',
order: 200,
component: () => null,
shouldShow() {
return false;
}
}
];
});
it('should render as expected', () => {
const component = mount(
<InspectorPanel
adapters={adapters}
onClose={() => true}
views={views}
/>
);
expect(component).toMatchSnapshot();
});
it('should not allow updating adapters', () => {
const component = mount(
<InspectorPanel
adapters={adapters}
onClose={() => true}
views={views}
/>
);
adapters.notAllowed = {};
expect(() => component.setProps({ adapters })).toThrow();
});
});

View file

@ -0,0 +1,53 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// TODO: Remove once EUI has typing for EuiFlyoutBody
declare module '@elastic/eui' {
export const EuiFlyoutBody: React.SFC<any>;
}
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.
*/
const InspectorView: React.SFC<{ useFlex?: boolean }> = ({
useFlex,
children,
}) => {
const classes = classNames({
'inspector-view__flex': Boolean(useFlex),
});
return <EuiFlyoutBody className={classes}>{children}</EuiFlyoutBody>;
};
InspectorView.propTypes = {
/**
* Set to true if the element should have display: flex set.
*/
useFlex: PropTypes.bool,
};
export { InspectorView };

View file

@ -0,0 +1,124 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiButtonEmpty,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiPopover,
EuiToolTip,
} from '@elastic/eui';
class InspectorViewChooser extends Component {
state = {
isSelectorOpen: false
};
toggleSelector = () => {
this.setState((prev) => ({
isSelectorOpen: !prev.isSelectorOpen
}));
};
closeSelector = () => {
this.setState({
isSelectorOpen: false
});
};
renderView = (view, index) => {
return (
<EuiContextMenuItem
key={index}
onClick={() => {
this.props.onViewSelected(view);
this.closeSelector();
}}
toolTipContent={view.help}
toolTipPosition="left"
data-test-subj={`inspectorViewChooser${view.title}`}
>
{view.title}
</EuiContextMenuItem>
);
}
renderViewButton() {
return (
<EuiButtonEmpty
size="s"
iconType="arrowDown"
iconSide="right"
onClick={this.toggleSelector}
data-test-subj="inspectorViewChooser"
>
View: { this.props.selectedView.title }
</EuiButtonEmpty>
);
}
renderSingleView() {
return (
<EuiToolTip
position="bottom"
content={this.props.selectedView.help}
>
<span>View: { this.props.selectedView.title }</span>
</EuiToolTip>
);
}
render() {
const { views } = this.props;
if (views.length < 2) {
return this.renderSingleView();
}
const triggerButton = this.renderViewButton();
return (
<EuiPopover
id="inspectorViewChooser"
ownFocus
button={triggerButton}
isOpen={this.state.isSelectorOpen}
closePopover={this.closeSelector}
panelPaddingSize="none"
anchorPosition="downRight"
>
<EuiContextMenuPanel
items={views.map(this.renderView)}
/>
</EuiPopover>
);
}
}
InspectorViewChooser.propTypes = {
views: PropTypes.array.isRequired,
onViewSelected: PropTypes.func.isRequired,
selectedView: PropTypes.object.isRequired,
};
export { InspectorViewChooser };

View file

@ -0,0 +1,104 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
InspectorViewDescription,
InspectorViewRegistry,
} from './view_registry';
import { Adapters } from './types';
function createMockView(
params: {
help?: string;
order?: number;
shouldShow?: (view?: Adapters) => boolean;
title?: string;
} = {}
): InspectorViewDescription {
return {
component: () => null,
help: params.help || 'help text',
order: params.order,
shouldShow: params.shouldShow,
title: params.title || 'view',
};
}
describe('InspectorViewRegistry', () => {
let registry: InspectorViewRegistry;
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({ title: 'view1', order: 2000 });
const view2 = createMockView({ title: 'view2', order: 1000 });
registry.register(view1);
registry.register(view2);
const views = registry.getAll();
expect(views.map(v => v.title)).toEqual(['view2', 'view1']);
});
describe('getVisible()', () => {
it('should return empty array on passing undefined to the registry', () => {
const view1 = createMockView({ title: 'view1', shouldShow: () => true });
const view2 = createMockView({ title: 'view2', shouldShow: () => false });
registry.register(view1);
registry.register(view2);
const views = registry.getVisible();
expect(views).toEqual([]);
});
it('should only return matching views', () => {
const view1 = createMockView({ title: 'view1', shouldShow: () => true });
const view2 = createMockView({ title: 'view2', shouldShow: () => false });
registry.register(view1);
registry.register(view2);
const views = registry.getVisible({});
expect(views.map(v => v.title)).toEqual(['view1']);
});
it('views without shouldShow should be included', () => {
const view1 = createMockView({ title: 'view1', shouldShow: () => true });
const view2 = createMockView({ title: 'view2' });
registry.register(view1);
registry.register(view2);
const views = registry.getVisible({});
expect(views.map(v => v.title)).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: () => null };
registry.getVisible(adapter);
expect(shouldShow).toHaveBeenCalledWith(adapter);
});
});
});

View file

@ -0,0 +1,86 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EventEmitter } from 'events';
import { Adapters, InspectorViewDescription } from './types';
/**
* @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.
*/
/**
* A registry that will hold inspector views.
*/
class InspectorViewRegistry extends EventEmitter {
private views: InspectorViewDescription[] = [];
/**
* 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.
*/
public register(view: InspectorViewDescription): void {
if (!view) {
return;
}
this.views.push(view);
// Keep registry sorted by the order property
this.views.sort(
(a, b) => (a.order || Number.MAX_VALUE) - (b.order || Number.MAX_VALUE)
);
this.emit('change');
}
/**
* Retrieve all views currently registered with the registry.
* @returns {InspectorViewDescription[]} A by `order` sorted list of all registered
* inspector views.
*/
public getAll(): InspectorViewDescription[] {
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.
*/
public getVisible(adapters?: Adapters): InspectorViewDescription[] {
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, InspectorViewDescription };

View file

@ -20,6 +20,7 @@
aria-label="{{::menuItem.description}}"
aria-haspopup="{{!menuItem.hasFunction}}"
aria-expanded="{{kbnTopNav.isCurrent(menuItem.key)}}"
aria-disabled="{{menuItem.disableButton()}}"
ng-class="{'kuiLocalMenuItem-isSelected': kbnTopNav.isCurrent(menuItem.key), 'kuiLocalMenuItem-isDisabled': menuItem.disableButton()}"
ng-click="kbnTopNav.handleClick(menuItem)"
ng-bind="menuItem.label"

View file

@ -274,25 +274,6 @@
}
}
// /src/ui/public/visualize/visualize.less
.visualize-show-spy {
border-top-color: @visualize-show-spy-border;
}
visualize-spy {
background-color: @visualize-spy-container-pre-bg;
}
.visualize-spy-container {
pre {
border-color: @visualize-show-spy-border;
color: @visualize-spy-container-pre-color;
opacity: 1;
background-color: @visualize-spy-container-pre-bg;
}
}
// /src/ui/public/vislib/styles/_svg.less
.axis {
line, path {

View file

@ -77,14 +77,6 @@
@collapser-hover-bg: @gray6;
@collapser-hover-color: @gray3;
@visualize-show-spy-border: @collapser-border;
@visualize-show-spy-bg: @collapser-bg;
@visualize-show-spy-color: @collapser-color;
@visualize-show-spy-hover-bg: @collapser-hover-bg;
@visualize-show-spy-hover-color: @collapser-hover-color;
@visualize-spy-container-pre-color: #a6a6a6;
@visualize-spy-container-pre-bg: darken(@panel-bg, 5%);
@svg-axis-color: @gray8;
@svg-tick-text-color: @gray10;
@svg-brush-color: @white;

View file

@ -137,13 +137,6 @@
@settings-add-data-wizard-parse-csv-container-border: @kibanaBlue3;
// Visualize ===================================================================
@visualize-show-spy-border: @gray-lighter;
@visualize-show-spy-bg: @white;
@visualize-show-spy-color: @gray3;
@visualize-show-spy-hover-bg: @gray-lighter;
@visualize-show-spy-hover-color: @gray3;
@visualize-info-bg: @globalColorLightGray;
@ -172,8 +165,8 @@
@vis-editor-agg-wide-btn-border: @gray-lighter;
@vis-editor-agg-wide-btn-bg: @body-bg;
@vis-editor-agg-wide-btn-hover-bg: @visualize-show-spy-hover-bg;
@vis-editor-agg-wide-btn-hover-color: @visualize-show-spy-hover-color;
@vis-editor-agg-wide-btn-hover-bg: @gray-lighter;
@vis-editor-agg-wide-btn-hover-color: @gray3;
@vis-editor-agg-editor-order-bg: transparent;
@vis-editor-agg-editor-order-border: @gray-lighter;

View file

@ -19,10 +19,13 @@
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 { Inspector } from '../../inspector/inspector';
describe('Vis Class', function () {
let indexPattern;
@ -106,4 +109,151 @@ 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, 'isAvailable');
vis.hasInspector();
expect(Inspector.isAvailable.calledOnce).to.be(true);
const adapters = Inspector.isAvailable.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, 'isAvailable');
stub.returns(true);
expect(vis.hasInspector()).to.be(true);
stub.returns(false);
expect(vis.hasInspector()).to.be(false);
});
afterEach(() => {
Inspector.isAvailable.restore();
});
});
describe('openInspector()', () => {
beforeEach(() => {
sinon.stub(Inspector, 'open');
});
it('should call openInspector with all attached inspectors', () => {
const Foodapter = class {};
const vis = new Vis(indexPattern, state({
inspectorAdapters: {
data: true,
custom: {
foo: Foodapter
}
}
}));
vis.openInspector();
expect(Inspector.open.calledOnce).to.be(true);
const adapters = Inspector.open.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' });
vis.openInspector();
expect(Inspector.open.calledOnce).to.be(true);
const params = Inspector.open.lastCall.args[1];
expect(params.title).to.be('beautifulVis');
});
afterEach(() => {
Inspector.open.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);
});
});
});
});

View file

@ -22,7 +22,6 @@
vis-data="visData"
ui-state="uiState"
search-source="searchSource"
show-spy-panel="showSpyPanel"
listen-on-change="false"
/>
</div>

View file

@ -34,10 +34,9 @@ const defaultEditor = function ($rootScope, $compile) {
return class DefaultEditor {
static key = 'default';
constructor(el, vis, showSpyPanel) {
constructor(el, vis) {
this.el = $(el);
this.vis = vis;
this.showSpyPanel = showSpyPanel;
if (!this.vis.type.editorConfig.optionTabs && this.vis.type.editorConfig.optionsTemplate) {
this.vis.type.editorConfig.optionTabs = [
@ -50,7 +49,6 @@ const defaultEditor = function ($rootScope, $compile) {
let $scope;
const updateScope = () => {
$scope.showSpyPanel = this.showSpyPanel;
$scope.vis = this.vis;
$scope.visData = visData;
$scope.uiState = uiState;

View file

@ -21,10 +21,56 @@ import _ from 'lodash';
import { SearchSourceProvider } from '../../courier/data_source/search_source';
import { VisRequestHandlersRegistryProvider } from '../../registry/vis_request_handlers';
import { calculateObjectHash } from '../lib/calculate_object_hash';
import { getRequestInspectorStats, getResponseInspectorStats } from '../../courier/utils/courier_inspector_utils';
import { tabifyAggResponse } from '../../agg_response/tabify/tabify';
import { FormattedData } from '../../inspector/adapters';
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,
isHierarchical: vis.isHierarchical(),
});
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.raw);
vis.API.queryFilter.addFilters(filter);
}),
filterOut: isCellContentFilterable && ((value) => {
const filter = col.aggConfig.createFilter(value.raw);
filter.meta = filter.meta || {};
filter.meta.negate = true;
vis.API.queryFilter.addFilters(filter);
}),
});
});
const rows = table.rows.map(row => {
return row.reduce((prev, cur, index) => {
const fieldFormatter = table.columns[index].aggConfig.fieldFormatter('text');
prev[`col${index}`] = new FormattedData(cur, fieldFormatter(cur));
return prev;
}, {});
});
return { columns, rows };
}
return {
name: 'courier',
handler: function (vis, { searchSource, timeRange, query, filters, forceFetch }) {
@ -75,8 +121,19 @@ const CourierRequestHandlerProvider = function (Private, courier, timefilter) {
return requestSearchSource.getSearchRequestBody().then(q => {
const queryHash = calculateObjectHash(q);
if (shouldQuery(queryHash)) {
vis.API.inspectorAdapters.requests.reset();
const request = vis.API.inspectorAdapters.requests.start('Data', {
description: `This request queries Elasticsearch to fetch the data for the visualization.`,
});
request.stats(getRequestInspectorStats(requestSearchSource));
requestSearchSource.onResults().then(resp => {
searchSource.lastQuery = queryHash;
request
.stats(getResponseInspectorStats(searchSource, resp))
.ok({ json: resp });
searchSource.rawResponse = resp;
return _.cloneDeep(resp);
}).then(async resp => {
@ -88,9 +145,19 @@ const CourierRequestHandlerProvider = function (Private, courier, timefilter) {
}
searchSource.finalResponse = resp;
vis.API.inspectorAdapters.data.setTabularLoader(
() => buildTabularInspectorData(vis, searchSource),
{ returnsFormattedValues: true }
);
resolve(resp);
}).catch(e => reject(e));
requestSearchSource.getSearchRequestBody().then(req => {
request.json(req);
});
courier.fetch();
} else {
resolve(searchSource.finalResponse);

View file

@ -40,6 +40,9 @@ import { queryManagerFactory } from '../query_manager';
import { SearchSourceProvider } from '../courier/data_source/search_source';
import { SavedObjectsClientProvider } from '../saved_objects';
import { Inspector } from '../inspector';
import { RequestAdapter, DataAdapter } from '../inspector/adapters';
const getTerms = (table, columnIndex, rowIndex) => {
// get only rows where cell value matches current row for all the fields before columnIndex
const rows = table.rows.filter(row => row.every((cell, i) => cell === table.rows[rowIndex][i] || i >= columnIndex));
@ -114,10 +117,59 @@ 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 Inspector.open(this.API.inspectorAdapters, {
title: this.title
});
}
hasInspector() {
return Inspector.isAvailable(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 = {};
const { inspectorAdapters: typeAdapters } = this.type;
// 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 (typeAdapters && typeAdapters.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 (typeAdapters && typeAdapters.data || this.type.requestHandler === 'courier') {
adapters.data = new DataAdapter();
}
// Add all inspectors, that are explicitly registered with this vis type
if (typeAdapters && typeAdapters.custom) {
Object.entries(typeAdapters.custom).forEach(([key, Adapter]) => {
adapters[key] = new Adapter();
});
}
return adapters;
}
isEditorMode() {
return this.editorMode || false;
}

View file

@ -1,331 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import $ from 'jquery';
import sinon from 'sinon';
import { expect } from 'chai';
import ngMock from 'ng_mock';
import angular from 'angular';
import { VisProvider } from '../../vis';
import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern';
import FixturesStubbedSearchSourceProvider from 'fixtures/stubbed_search_source';
import { uiRegistry } from '../../registry/_registry';
import { SpyModesRegistryProvider } from '../../registry/spy_modes';
import mockUiState from 'fixtures/mock_ui_state';
describe('visualize spy panel', function () {
let $scope;
let $compile;
let $timeout;
let $el;
let visElement;
let Vis;
let indexPattern;
let fixtures;
let searchSource;
let vis;
let spyModeStubRegistry;
beforeEach(ngMock.module('kibana', 'kibana/table_vis', (PrivateProvider) => {
spyModeStubRegistry = uiRegistry({
name: 'spyModes',
index: ['name'],
order: ['order']
});
PrivateProvider.swap(SpyModesRegistryProvider, spyModeStubRegistry);
}));
beforeEach(ngMock.inject(function (Private, $injector) {
$scope = $injector.get('$rootScope').$new();
$timeout = $injector.get('$timeout');
$compile = $injector.get('$compile');
visElement = angular.element('<div>');
visElement.width(500);
visElement.height(500);
fixtures = require('fixtures/fake_hierarchical_data');
Vis = Private(VisProvider);
indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider);
searchSource = Private(FixturesStubbedSearchSourceProvider);
vis = new CreateVis(null, false);
init(vis, fixtures.oneRangeBucket);
}));
// basically a parameterized beforeEach
function init(vis, esResponse) {
vis.aggs.forEach(function (agg, i) { agg.id = 'agg_' + (i + 1); });
mockUiState._reset();
$scope.vis = vis;
$scope.esResponse = esResponse;
$scope.uiState = mockUiState;
$scope.searchSource = searchSource;
$scope.visElement = visElement;
}
function CreateVis(params, requiresSearch) {
const vis = new Vis(indexPattern, {
type: 'table',
params: params || {},
aggs: [
{ type: 'count', schema: 'metric' },
{
type: 'range',
schema: 'bucket',
params: {
field: 'bytes',
ranges: [
{ from: 0, to: 1000 },
{ from: 1000, to: 2000 }
]
}
}
]
});
vis.type.requestHandler = requiresSearch ? 'default' : 'none';
vis.type.responseHandler = 'none';
vis.type.requiresSearch = false;
return vis;
}
function compile() {
const spyElem = $('<visualize-spy vis="vis" vis-element="visElement" search-source="searchSource" ui-state="uiState">');
const $el = $compile(spyElem)($scope);
$scope.$apply();
$el.toggleButton = $el.find('[data-test-subj="spyToggleButton"]');
$el.maximizedButton = $el.find('[data-test-subj="toggleSpyFullscreen"]');
$el.panel = $el.find('[data-test-subj="spyContainer"]');
$el.tabs = $el.find('[data-test-subj="spyModTabs"]');
return $el;
}
function fillRegistryAndCompile() {
spyModeStubRegistry.register(() => ({
name: 'spymode1',
display: 'SpyMode1',
order: 1,
template: '<div></div>',
}));
spyModeStubRegistry.register(() => ({
name: 'spymode2',
display: 'SpyMode2',
order: 2,
template: '<div></div>',
}));
$el = compile();
}
function openSpy(el = $el) {
el.toggleButton.click();
}
// Returns an array of the title of all shown mode tabs.
function getModeTabTitles($el) {
const tabElems = $el.tabs.find('button').get();
return tabElems.map(btn => btn.textContent.trim());
}
describe('toggle button', () => {
it('should not be shown if no spy mode is registered', () => {
const $el = compile();
expect($el.toggleButton.length).to.equal(0);
});
});
describe('open and closing the spy panel', () => {
beforeEach(fillRegistryAndCompile);
it('should show spy-panel on toggle click', () => {
expect($el.panel.hasClass('ng-hide')).to.equal(true);
$el.toggleButton.click();
expect($el.panel.hasClass('ng-hide')).to.equal(false);
});
it('should hide spy-panel on toggle button, when opened', () => {
$el.toggleButton.click();
expect($el.panel.hasClass('ng-hide')).to.equal(false);
$el.toggleButton.click();
expect($el.panel.hasClass('ng-hide')).to.equal(true);
});
});
describe('maximized mode', () => {
beforeEach(fillRegistryAndCompile);
it('should toggle to maximized mode when maximized button is clicked', () => {
openSpy();
$el.maximizedButton.click();
expect($el.panel.hasClass('only')).to.equal(true);
expect(visElement.hasClass('spy-only')).to.equal(true);
});
it('should exit maximized mode on a second click', () => {
openSpy();
$el.maximizedButton.click();
expect($el.panel.hasClass('only')).to.equal(true);
expect(visElement.hasClass('spy-only')).to.equal(true);
$el.maximizedButton.click();
expect($el.panel.hasClass('only')).to.equal(false);
expect(visElement.hasClass('spy-only')).to.equal(false);
});
it('will be forced when vis would be too small otherwise', () => {
visElement.height(50);
openSpy();
$timeout.flush();
expect($el.panel.hasClass('only')).to.equal(true);
expect(visElement.hasClass('spy-only')).to.equal(true);
});
it('should not trigger forced maximized mode, when spy is not shown', () => {
visElement.height(50);
compile();
$timeout.flush();
expect(visElement.hasClass('spy-only')).to.equal(false);
});
});
describe('spy modes', () => {
function registerRegularPanels() {
spyModeStubRegistry.register(() => ({
name: 'spymode2',
display: 'SpyMode2',
order: 2,
template: '<div class="spymode2"></div>',
}));
spyModeStubRegistry.register(() => ({
name: 'spymode1',
display: 'SpyMode1',
order: 1,
template: '<div class="spymode1"></div>',
}));
}
it('should show registered spy modes as tabs', () => {
registerRegularPanels();
const $el = compile();
openSpy($el);
expect($el.tabs.find('button').length).to.equal(2);
expect(getModeTabTitles($el)).to.eql(['SpyMode1', 'SpyMode2']);
});
it('should by default be on the first spy mode when opening', async () => {
registerRegularPanels();
const $el = compile();
openSpy($el);
expect($el.panel.find('.spymode1').length).to.equal(1);
});
describe('conditional spy modes', () => {
let filterOutSpy;
beforeEach(() => {
filterOutSpy = sinon.spy(() => false);
spyModeStubRegistry.register(() => ({
name: 'test',
display: 'ShouldBeFiltered',
showMode: filterOutSpy,
order: 1,
template: '<div></div>'
}));
spyModeStubRegistry.register(() => ({
name: 'test2',
display: 'ShouldNotBeFiltered',
order: 2,
template: '<div></div>'
}));
spyModeStubRegistry.register(() => ({
name: 'test3',
display: 'Test3',
order: 3,
showMode: () => true,
template: '<div></div>'
}));
$el = compile();
openSpy();
});
it('should filter out panels, that return false in showMode', () => {
expect(getModeTabTitles($el)).not.to.include('ShouldBeFiltered');
});
it('should show modes without a showMode function', () => {
expect(getModeTabTitles($el)).to.include('ShouldNotBeFiltered');
});
it('should show mods whose showMode returns true', () => {
expect(getModeTabTitles($el)).to.include('Test3');
});
it('should pass the visualization to the showMode method', () => {
expect(filterOutSpy.called).to.equal(true);
expect(filterOutSpy.getCall(0).args[0]).to.equal(vis);
});
});
describe('uiState', () => {
beforeEach(fillRegistryAndCompile);
it('should sync the active tab to the uiState', () => {
expect($scope.uiState.get('spy.mode.name', null)).to.be.null;
openSpy();
expect($scope.uiState.get('spy.mode.name', null)).to.equal('spymode1');
});
it('should sync uiState when closing the panel', () => {
openSpy();
expect($scope.uiState.get('spy.mode.name', null)).to.equal('spymode1');
$el.toggleButton.click();
expect($scope.uiState.get('spy.mode.name', null)).to.equal(null);
});
it('should sync uiState when maximizing', () => {
openSpy();
expect($scope.uiState.get('spy.mode.fill', null)).to.equal(null);
$el.maximizedButton.click(); // Maximize it initially
expect($scope.uiState.get('spy.mode.fill', false)).to.equal(true);
$el.maximizedButton.click(); // Reset maximized state again
expect($scope.uiState.get('spy.mode.fill', false)).to.equal(false);
});
it('should also reset fullscreen when closing panel', () => {
openSpy();
$el.maximizedButton.click();
expect($scope.uiState.get('spy.mode.fill', false)).to.equal(true);
$el.toggleButton.click(); // Close spy panel
expect($scope.uiState.get('spy.mode.fill', null)).to.equal(null);
});
});
});
});

View file

@ -30,6 +30,7 @@ import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logsta
import { VisProvider } from '../../../vis';
import { getVisualizeLoader } from '../loader';
import { EmbeddedVisualizeHandler } from '../embedded_visualize_handler';
import { Inspector } from '../../../inspector/inspector';
describe('visualize loader', () => {
@ -228,6 +229,15 @@ describe('visualize loader', () => {
expect(handler.getElement().jquery).to.be.ok();
});
it('should allow opening the inspector of the visualization and return its session', () => {
const handler = loader.embedVisualizationWithSavedObject(newContainer(), createSavedObject(), {});
sinon.spy(Inspector, 'open');
const inspectorSession = handler.openInspector();
expect(Inspector.open.calledOnce).to.be(true);
expect(inspectorSession.close).to.be.a('function');
inspectorSession.close();
});
it('should have whenFirstRenderComplete returns a promise resolving on first renderComplete event', async () => {
const container = newContainer();
const handler = loader.embedVisualizationWithSavedObject(container, createSavedObject(), {});

View file

@ -26,9 +26,10 @@ const RENDER_COMPLETE_EVENT = 'render_complete';
* with the visualization.
*/
export class EmbeddedVisualizeHandler {
constructor(element, scope) {
constructor(element, scope, savedObject) {
this._element = element;
this._scope = scope;
this._savedObject = savedObject;
this._listeners = new EventEmitter();
// Listen to the first RENDER_COMPLETE_EVENT to resolve this promise
this._firstRenderComplete = new Promise(resolve => {
@ -88,6 +89,15 @@ export class EmbeddedVisualizeHandler {
return this._element;
}
/**
* Opens the inspector for the embedded visualization. This will return an
* handler to the inspector to close and interact with it.
* @return {InspectorSession} An inspector session to interact with the opened inspector.
*/
openInspector() {
return this._savedObject.vis.openInspector();
}
/**
* Returns a promise, that will resolve (without a value) once the first rendering of
* the visualization has finished. If you want to listen to concecutive rendering

View file

@ -42,10 +42,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)
@ -66,7 +62,6 @@ const VisualizeLoaderProvider = ($compile, $rootScope, savedVisualizations) => {
scope.timeRange = params.timeRange;
scope.filters = params.filters;
scope.query = params.query;
scope.showSpyPanel = params.showSpyPanel;
const container = angular.element(el);
@ -91,7 +86,7 @@ const VisualizeLoaderProvider = ($compile, $rootScope, savedVisualizations) => {
container.html(visHtml);
}
return new EmbeddedVisualizeHandler(visHtml, scope);
return new EmbeddedVisualizeHandler(visHtml, scope, savedObj);
};
return {

View file

@ -5,6 +5,5 @@
time-range="timeRange"
filters="filters"
query="query"
show-spy-panel="showSpyPanel"
render-complete
></visualize>

View file

@ -1,60 +0,0 @@
<div
class="visualize-show-spy"
ng-if="modes.length > 0"
>
<button
data-test-subj="spyToggleButton"
ng-click="toggleDisplay()"
class="kuiCollapseButton visualize-show-spy-tab"
type="button"
>
<span
class="kuiIcon"
ng-class="shouldShowSpyPanel() ? 'fa-chevron-circle-down' : 'fa-chevron-circle-up'"
></span>
</button>
</div>
<div
data-test-subj="spyContainer"
class="visualize-spy-container"
ng-class="{ 'only': maximizedSpy || forceMaximized }"
ng-show="shouldShowSpyPanel()"
>
<div class="visualize-spy__tab-container euiTabs euiTabs--small">
<div role="tablist" class="visualize-spy__tabs" data-test-subj="spyModTabs">
<button
role="tab"
class="euiTab"
ng-repeat="mode in modes"
aria-selected="{{mode.name === currentMode}}"
ng-class="{'euiTab-isSelected': mode.name === currentMode}"
data-test-subj="spyModeSelect-{{::mode.name}}"
ng-click="setSpyMode(mode.name)"
>
<span class="euiTab__content">
{{::mode.display}}
</span>
</button>
</div>
<button
data-test-subj="toggleSpyFullscreen"
aria-label="Toggle spy panel fullscreen"
class="kuiButton kuiButton--hollow"
ng-hide="forceMaximized"
ng-click="toggleMaximize()"
>
<span
class="kuiIcon"
ng-class="currentMode.fill ? 'fa-compress' : 'fa-expand'"
></span>
</button>
</div>
<div
data-test-subj="spyContentContainer"
data-spy-content-container
class="kuiVerticalRhythm visualize-spy__content-container"
></div>
</div>

View file

@ -1,201 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import $ from 'jquery';
import { SpyModesRegistryProvider } from '../registry/spy_modes';
import { uiModules } from '../modules';
import spyTemplate from './spy.html';
import { PersistedState } from '../persisted_state';
uiModules
.get('app/visualize')
.directive('visualizeSpy', function (Private, $compile, $timeout) {
const spyModes = Private(SpyModesRegistryProvider);
return {
restrict: 'E',
template: spyTemplate,
scope: {
vis: '<',
searchSource: '<',
uiState: '<',
visElement: '<',
},
link: function ($scope, $el) {
// If no uiState has been passed, create a local one for this spy.
if (!$scope.uiState) $scope.uiState = new PersistedState({});
let currentSpy;
let defaultModeName;
const $container = $el.find('[data-spy-content-container]');
$scope.modes = [];
$scope.currentMode = null;
$scope.maximizedSpy = false;
$scope.forceMaximized = false;
function checkForcedMaximized() {
$timeout(() => {
if ($scope.visElement && $scope.currentMode && $scope.visElement.height() < 180) {
$scope.forceMaximized = true;
} else {
$scope.forceMaximized = false;
}
});
}
checkForcedMaximized();
/**
* Filter for modes that should actually be active for this visualization.
* This will call the showMode method of the mode, pass it the vis object.
* Depending on whether or not that returns a truthy value, it will be shown
* or not. If the method is not present, the mode will always be shown.
*/
function filterModes() {
$scope.modes = spyModes.inOrder.filter(mode =>
mode.showMode ? mode.showMode($scope.vis) : true
);
defaultModeName = $scope.modes.length > 0 ? $scope.modes[0].name : null;
}
filterModes();
$scope.$watch('vis', filterModes);
function syncFromUiState() {
$scope.currentMode = $scope.uiState.get('spy.mode.name');
$scope.maximizedSpy = $scope.uiState.get('spy.mode.fill');
}
/**
* Write our current state into the uiState.
* This will write the name and fill (maximized) into the uiState
* if a panel is opened (currentMode is set) or it will otherwise
* remove the spy key from the uiState.
*/
function updateUiState() {
if ($scope.currentMode) {
$scope.uiState.set('spy.mode', {
name: $scope.currentMode,
fill: $scope.maximizedSpy,
});
} else {
$scope.uiState.set('spy', null);
}
}
// Initially sync the panel state from the uiState.
syncFromUiState();
// Whenever the uiState changes, update the settings from it.
$scope.uiState.on('change', syncFromUiState);
$scope.$on('$destroy', () => $scope.uiState.off('change', syncFromUiState));
$scope.setSpyMode = function setSpyMode(modeName) {
$scope.currentMode = modeName;
updateUiState();
$scope.$emit('render');
};
$scope.toggleDisplay = function () {
// If the spy panel is already shown (a currentMode is set),
// close the panel by setting the name to null, otherwise open the
// panel (i.e. set it to the default mode name).
if ($scope.currentMode) {
$scope.setSpyMode(null);
$scope.forceMaximized = false;
} else {
$scope.setSpyMode(defaultModeName);
checkForcedMaximized();
}
};
/**
* Should we currently show the spy panel. True if a currentMode has been set.
*/
$scope.shouldShowSpyPanel = () => {
return !!$scope.currentMode;
};
/**
* Toggle maximized state of spy panel and update the UI state.
*/
$scope.toggleMaximize = function () {
$scope.maximizedSpy = !$scope.maximizedSpy;
updateUiState();
};
/**
* Whenever the maximized state changes, we also need to toggle the class
* of the visualization.
*/
$scope.$watchMulti(['maximizedSpy', 'forceMaximized'], () => {
$scope.visElement.toggleClass('spy-only', $scope.maximizedSpy || $scope.forceMaximized);
});
/**
* Watch for changes of the currentMode. Whenever it changes, we render
* the new mode into the template. Therefore we remove the previously rendered
* mode (if existing) and compile and bind the template of the new mode.
*/
$scope.$watch('currentMode', (mode, prevMode) => {
if (mode === prevMode && (currentSpy && currentSpy.mode === mode)) {
// When the mode hasn't changed and we have already rendered it, return.
return;
}
const newMode = spyModes.byName[mode];
if (currentSpy) {
// If we already have a spy loaded, remove that HTML element and
// destroy the previous Angular scope.
currentSpy.$container.remove();
currentSpy.$scope.$destroy();
currentSpy = null;
}
// If we want haven't specified a new mode we won't do anything further.
if (!newMode) {
// Reset the forced maximized flag if we are about to close the panel.
$scope.forceMaximized = false;
return;
}
const contentScope = $scope.$new();
const contentContainer = $('<div class="visualize-spy-content">');
contentContainer.append($compile(newMode.template)(contentScope));
$container.append(contentContainer);
currentSpy = {
$scope: contentScope,
$container: contentContainer,
mode: mode,
};
newMode.link && newMode.link(currentSpy.$scope, currentSpy.$element);
});
}
};
});

View file

@ -20,10 +20,3 @@
ng-class="{ loading: vis.type.requiresSearch && searchSource.activeFetchCount > 0 }"
class="visualize-chart"></div>
</div>
<visualize-spy
ng-if="showSpyPanel"
vis="vis"
vis-element="visElement"
search-source="searchSource"
ui-state="uiState"
></visualize-spy>

View file

@ -19,7 +19,6 @@
import * as Rx from 'rxjs';
import { tap, debounceTime, filter, share, switchMap } from 'rxjs/operators';
import './spy';
import './visualize.less';
import _ from 'lodash';
import { uiModules } from '../modules';
@ -36,7 +35,6 @@ uiModules
return {
restrict: 'E',
scope: {
showSpyPanel: '=?',
vis: '=',
visData: '=',
uiState: '=?',

View file

@ -17,7 +17,6 @@
* under the License.
*/
import './spy';
import './visualize.less';
import './visualize_legend';
import { uiModules } from '../modules';
@ -33,7 +32,6 @@ uiModules
return {
restrict: 'E',
scope: {
showSpyPanel: '=',
vis: '=',
visData: '=',
uiState: '=?',
@ -44,7 +42,7 @@ uiModules
const vis = $scope.vis;
const Editor = typeof vis.type.editor === 'function' ? vis.type.editor :
editorTypes.find(editor => editor.key === vis.type.editor);
const editor = new Editor(element[0], vis, $scope.showSpyPanel);
const editor = new Editor(element[0], vis);
$scope.renderFunction = () => {
if (!$scope.vis) return;

View file

@ -6,7 +6,6 @@
ui-state="uiState"
class="vis-editor-content"
search-source="savedObj.searchSource"
show-spy-panel="showSpyPanel"
/>
<visualization
ng-if="editorMode!==true"
@ -16,5 +15,4 @@
ui-state="uiState"
listen-on-change="false"
search-source="savedObj.searchSource"
show-spy-panel="showSpyPanel"
/>

View file

@ -48,7 +48,6 @@ uiModules
return {
restrict: 'E',
scope: {
showSpyPanel: '=?',
editorMode: '=?',
savedObj: '=?',
appState: '=?',
@ -113,8 +112,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())

View file

@ -34,10 +34,6 @@ visualization {
flex: 1 0;
}
&.spy-only {
display: none;
}
}
.loading {
@ -66,125 +62,3 @@ visualization {
.item { }
.bottom { align-self: flext-end; }
}
visualize-spy {
background-color: #ffffff;
z-index: 1000;
// this element should flex
flex: 0 1 auto;
padding: 0px 0px 0px 15px;
// it's children should also flex vertically
flex-direction: column;
display: flex;
overflow: auto;
&.visible {
display: block;
}
&.only {
flex: 1 1 auto;
padding-top: 0px;
}
/**
* 1. Prevent clipping the focused state of buttons at the top of the container.
*/
.visualize-spy-container {
padding-top: 2px; /* 1 */
&.only {
height: auto;
}
}
pre {
word-break: break-all;
word-wrap: break-word;
white-space: pre-wrap;
}
}
/**
* 1. Restrict height of the spy and scroll if the content exceeds this height. This prevents
* the spy from pushing surrounding content around, e.g. pushing the table down in Discover.
*/
.visualize-spy-container {
flex: 1 1 auto;
display: flex;
flex-direction: column;
height: 482px; /* 1 */
overflow-y: auto; /* 1 */
header {
padding: 0 0 15px;
}
header + * {
flex: 1 1 auto;
}
> .alert {
flex: 0 0 auto;
}
tr > td {
font-size: 0.85em;
}
}
.visualize-spy-nav {
flex: 0 0 auto;
}
.visualize-spy-content {
position: relative;
}
.visualize-spy-loading {
position: absolute;
top: 0px;
left: 0px;
right: 0px;
text-align: center;
}
.visualize-spy-loading-text {
display: inline-block;
margin: 0;
background: @alert-info-bg;
color: @alert-info-text;
padding: 5px 10px;
border-radius: @border-radius-base;
.spinner > * {
background-color: @alert-info-text;
}
}
.visualize-show-spy {
flex: 0 0 auto;
}
.visualize-show-spy-tab {
position: absolute;
z-index: 1000;
left: 5px;
bottom: 0px;
}
.visualize-spy__content-container {
overflow: auto;
flex: 1 1 auto;
}
.visualize-spy__tab-container {
flex: 0 0 auto;
}
.visualize-spy__tabs {
flex: 1 1 auto;
}

View file

@ -40,7 +40,7 @@ export {
embeddableFactories,
fieldFormats,
fieldFormatEditors,
spyModes,
inspectorViews,
chromeNavControls,
navbarExtensions,
dashboardPanelActions,

View file

@ -42,7 +42,6 @@ export const embeddableFactories = appExtension;
export const dashboardPanelActions = appExtension;
export const fieldFormats = appExtension;
export const fieldFormatEditors = appExtension;
export const spyModes = appExtension;
export const chromeNavControls = appExtension;
export const navbarExtensions = appExtension;
export const managementSections = appExtension;
@ -50,6 +49,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);

View file

@ -153,7 +153,6 @@ module.exports = function (grunt) {
flags: [
...funcTestServerFlags,
'--dev',
'--dev_mode.enabled=false',
'--no-base-path',
'--optimize.watchPort=5611',
'--optimize.watchPrebuild=true',

View file

@ -125,11 +125,9 @@ export default function ({ getService, getPageObjects }) {
await dashboardAddPanel.addVisualization('Visualization TileMap');
await PageObjects.dashboard.saveDashboard('No local edits');
await testSubjects.moveMouseTo('dashboardPanel');
await PageObjects.visualize.openSpyPanel();
const tileMapData = await PageObjects.visualize.getDataTableData();
await testSubjects.moveMouseTo('dashboardPanel');
await PageObjects.visualize.closeSpyPanel();
await dashboardPanelActions.openInspector();
const tileMapData = await PageObjects.visualize.getInspectorTableData();
await PageObjects.visualize.closeInspector();
await PageObjects.dashboard.clickEdit();
await dashboardPanelActions.clickEdit();
@ -143,11 +141,9 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.header.clickDashboard();
await testSubjects.moveMouseTo('dashboardPanel');
await PageObjects.visualize.openSpyPanel();
const changedTileMapData = await PageObjects.visualize.getDataTableData();
await testSubjects.moveMouseTo('dashboardPanel');
await PageObjects.visualize.closeSpyPanel();
await dashboardPanelActions.openInspector();
const changedTileMapData = await PageObjects.visualize.getInspectorTableData();
await PageObjects.visualize.closeInspector();
expect(changedTileMapData.length).to.not.equal(tileMapData.length);
});

View file

@ -21,7 +21,6 @@ import expect from 'expect.js';
export default function ({ getService, getPageObjects }) {
const retry = getService('retry');
const remote = getService('remote');
const dashboardPanelActions = getService('dashboardPanelActions');
const PageObjects = getPageObjects(['dashboard', 'visualize', 'header']);
@ -38,37 +37,6 @@ export default function ({ getService, getPageObjects }) {
});
});
it('does not show the spy pane toggle if mouse is not hovering', async () => {
// move mouse off the panel.
await PageObjects.header.clickTimepicker();
// no spy pane without hover
const spyToggleExists = await PageObjects.visualize.getSpyToggleExists();
expect(spyToggleExists).to.be(false);
});
it('shows the spy pane toggle on hover', async () => {
const panels = await PageObjects.dashboard.getDashboardPanels();
// Simulate hover
await remote.moveMouseTo(panels[0]);
const spyToggleExists = await PageObjects.visualize.getSpyToggleExists();
expect(spyToggleExists).to.be(true);
});
// This was an actual bug that appeared, where the spy pane appeared on panels after adding them, but
// disappeared when a new dashboard was opened up.
it('shows the spy pane toggle directly after opening a dashboard', async () => {
await PageObjects.dashboard.clickEdit();
await PageObjects.dashboard.saveDashboard('spy pane test', { saveAsNew: true });
await PageObjects.dashboard.gotoDashboardLandingPage();
await PageObjects.dashboard.loadSavedDashboard('spy pane test');
const panels = await PageObjects.dashboard.getDashboardPanels();
// Simulate hover
await remote.moveMouseTo(panels[1]);
const spyToggleExists = await PageObjects.visualize.getSpyToggleExists();
expect(spyToggleExists).to.be(true);
});
it('shows other panels after being minimized', async () => {
const panelCount = await PageObjects.dashboard.getPanelCount();
// Panels are all minimized on a fresh open of a dashboard, so we need to re-expand in order to then minimize.

View file

@ -87,57 +87,6 @@ export default function ({ getService, getPageObjects }) {
});
});
it.skip('doc view should show oldest time first', function () {
// Note: Could just check the timestamp, but might as well check that the whole doc is as expected.
const ExpectedDoc =
'September 22nd 2015, 23:50:13.253\ntype:apache index:logstash-2015.09.22 @timestamp:September 22nd 2015, 23:50:13.253'
+ ' ip:238.171.34.42 extension:jpg response:200 geo.coordinates:{ "lat": 38.66494528, "lon": -88.45299556'
+ ' } geo.src:FR geo.dest:KH geo.srcdest:FR:KH @tags:success, info utc_time:September 22nd 2015,'
+ ' 23:50:13.253 referer:http://twitter.com/success/nancy-currie agent:Mozilla/4.0 (compatible; MSIE 6.0;'
+ ' Windows NT 5.1; SV1; .NET CLR 1.1.4322) clientip:238.171.34.42 bytes:7,124'
+ ' host:media-for-the-masses.theacademyofperformingartsandscience.org request:/uploads/karl-henize.jpg'
+ ' url:https://media-for-the-masses.theacademyofperformingartsandscience.org/uploads/karl-henize.jpg'
+ ' @message:238.171.34.42 - - [2015-09-22T23:50:13.253Z] "GET /uploads/karl-henize.jpg HTTP/1.1" 200 7124'
+ ' "-" "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)" spaces:this is a'
+ ' thing with lots of spaces wwwwoooooo xss:<script>console.log("xss")</script>'
+ ' headings:<h3>alexander-viktorenko</h5>, http://nytimes.com/warning/michael-massimino'
+ ' links:@www.slate.com, http://www.slate.com/security/frederick-w-leslie, www.www.slate.com'
+ ' relatedContent:{ "url": "http://www.laweekly.com/music/bjork-at-the-nokia-theatre-12-12-2408191",'
+ ' "og:type": "article", "og:title": "Bjork at the Nokia Theatre, 12/12", "og:description": "Bjork at the'
+ ' Nokia Theater, December 12 By Randall Roberts Last night&rsquo;s Bjork show at the Dystopia &ndash;'
+ ' er, I mean Nokia -- Theatre downtown di...", "og:url": "'
+ 'http://www.laweekly.com/music/bjork-at-the-nokia-theatre-12-12-2408191", "article:published_time":'
+ ' "2007-12-13T12:19:35-08:00", "article:modified_time": "2014-11-27T08:28:42-08:00", "article:section":'
+ ' "Music", "og:image": "'
+ 'http://IMAGES1.laweekly.com/imager/bjork-at-the-nokia-theatre-12-12/u/original/2470701/bjorktn003.jpg",'
+ ' "og:image:height": "334", "og:image:width": "480", "og:site_name": "LA Weekly", "twitter:title":'
+ ' "Bjork at the Nokia Theatre, 12/12", "twitter:description": "Bjork at the Nokia Theater, December 12'
+ ' By Randall Roberts Last night&rsquo;s Bjork show at the Dystopia &ndash; er, I mean Nokia -- Theatre'
+ ' downtown di...", "twitter:card": "summary", "twitter:image": "'
+ 'http://IMAGES1.laweekly.com/imager/bjork-at-the-nokia-theatre-12-12/u/original/2470701/bjorktn003.jpg",'
+ ' "twitter:site": "@laweekly" }, { "url": "'
+ 'http://www.laweekly.com/music/the-rapture-at-the-mayan-7-25-2401011", "og:type": "article", "og:title":'
+ ' "The Rapture at the Mayan, 7/25", "og:description": "If you haven&rsquo;t yet experienced the'
+ ' phenomenon of people walk-dancing, apparently the best place to witness this is at a Rapture show.'
+ ' Here&rsquo;s...", "og:url": "http://www.laweekly.com/music/the-rapture-at-the-mayan-7-25-2401011",'
+ ' "article:published_time": "2007-07-26T12:42:30-07:00", "article:modified_time":'
+ ' "2014-11-27T08:00:51-08:00", "article:section": "Music", "og:image": "'
+ 'http://IMAGES1.laweekly.com/imager/the-rapture-at-the-mayan-7-25/u/original/2463272/rapturetn05.jpg",'
+ ' "og:image:height": "321", "og:image:width": "480", "og:site_name": "LA Weekly", "twitter:title": "The'
+ ' Rapture at the Mayan, 7/25", "twitter:description": "If you haven&rsquo;t yet experienced the'
+ ' phenomenon of people walk-dancing, apparently the best place to witness this is at a Rapture show.'
+ ' Here&rsquo;s...", "twitter:card": "summary", "twitter:image": "'
+ 'http://IMAGES1.laweekly.com/imager/the-rapture-at-the-mayan-7-25/u/original/2463272/rapturetn05.jpg",'
+ ' "twitter:site": "@laweekly" } machine.os:win 7 machine.ram:7,516,192,768 _id:AU_x3_g4GFA8no6QjkYX'
+ ' _type:doc _index:logstash-2015.09.22 _score: - relatedContent.article:modified_time:November 27th'
+ ' 2014, 16:00:51.000, November 27th 2014, 16:28:42.000 relatedContent.article:published_time:July 26th'
+ ' 2007, 19:42:30.000, December 13th 2007, 20:19:35.000';
return PageObjects.discover.getDocTableIndex(1)
.then(function (rowData) {
expect(rowData).to.be(ExpectedDoc);
});
});
it('doc view should sort ascending', function () {
// Note: Could just check the timestamp, but might as well check that the whole doc is as expected.
const ExpectedDoc =

Some files were not shown because too many files have changed in this diff Show more