diff --git a/docs/timelion.asciidoc b/docs/timelion.asciidoc index 895ec551863673..6fe13670f9a4e9 100644 --- a/docs/timelion.asciidoc +++ b/docs/timelion.asciidoc @@ -6,7 +6,7 @@ Timelion is a time series data visualizer that enables you to combine totally independent data sources within a single visualization. It's driven by a simple expression language you use to retrieve time series data, perform calculations -to tease out the answers to complex questions, and visualize the results. +to tease out the answers to complex questions, and visualize the results. For example, Timelion enables you to easily get the answers to questions like: @@ -32,7 +32,7 @@ Timelion expression as a Kibana dashboard panel. You can then add it to a dashboard like any other visualization. TIP: You can also create time series visualizations right from the Visualize -app--just select the Timeseries visualization type and enter a Timelion +app--just select the Timelion visualization type and enter a Timelion expression in the expression field. diff --git a/docs/visualize.asciidoc b/docs/visualize.asciidoc index 36c7b713356cb7..7b3ca7b6a33e02 100644 --- a/docs/visualize.asciidoc +++ b/docs/visualize.asciidoc @@ -34,7 +34,7 @@ instructions. <>:: Display words as a cloud in which the size of the word correspond to its importance <>:: Associate the results of an aggregation with geographic locations. -Timeseries:: Compute and combine data from multiple time series +Timelion:: Compute and combine data from multiple time series data sets. . Specify a search query to retrieve the data for your visualization: diff --git a/package.json b/package.json index 12fc20dacc67e2..3d690214dc78a8 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "brace": "0.5.1", "bunyan": "1.7.1", "check-hash": "1.0.1", + "color": "1.0.3", "commander": "2.8.1", "css-loader": "0.17.0", "d3": "3.5.6", @@ -187,12 +188,14 @@ "classnames": "2.2.5", "del": "1.2.1", "elasticdump": "2.1.1", + "enzyme": "2.7.0", "eslint": "3.11.1", "eslint-plugin-babel": "4.0.0", "eslint-plugin-mocha": "4.7.0", "event-stream": "3.3.2", "expect.js": "0.3.1", "faker": "1.1.0", + "flot-charts": "^0.8.3", "grunt": "1.0.1", "grunt-aws-s3": "0.14.5", "grunt-babel": "6.0.0", @@ -212,6 +215,7 @@ "image-diff": "1.6.0", "intern": "3.2.3", "istanbul-instrumenter-loader": "0.1.3", + "jsdom": "9.9.1", "karma": "1.2.0", "karma-chrome-launcher": "0.2.0", "karma-coverage": "0.5.1", @@ -232,15 +236,25 @@ "npm": "3.10.10", "portscanner": "1.0.0", "proxyquire": "1.7.10", + "pui-react-overlay-trigger": "^7.0.0", + "pui-react-tooltip": "^7.0.0", "react": "15.2.0", + "react-ace": "3.7.0", "react-addons-test-utils": "15.2.0", + "react-anything-sortable": "^1.6.1", + "react-color": "^2.2.7", "react-dom": "15.2.0", + "react-markdown": "^2.4.2", "react-redux": "4.4.5", "react-router": "2.0.0", "react-router-redux": "4.0.4", + "react-select": "^1.0.0-rc.1", + "react-sortable": "^1.1.0", + "reactcss": "^1.0.7", "redux": "3.0.0", "redux-thunk": "0.1.0", "sass-loader": "4.0.0", + "simianhacker-react-resize-aware": "^1.0.11", "simple-git": "1.37.0", "sinon": "1.17.2", "source-map": "0.5.6", diff --git a/src/core_plugins/metrics/index.js b/src/core_plugins/metrics/index.js new file mode 100644 index 00000000000000..e8b1ef31905593 --- /dev/null +++ b/src/core_plugins/metrics/index.js @@ -0,0 +1,31 @@ +import fieldsRoutes from './server/routes/fields'; +import visDataRoutes from './server/routes/vis'; + +export default function (kibana) { + return new kibana.Plugin({ + require: ['kibana','elasticsearch'], + + uiExports: { + visTypes: [ + 'plugins/metrics/kbn_vis_types' + ] + }, + + config(Joi) { + return Joi.object({ + enabled: Joi.boolean().default(true), + chartResolution: Joi.number().default(150), + minimumBucketSize: Joi.number().default(10) + }).default(); + }, + + + init(server, options) { + const { status } = server.plugins.elasticsearch; + fieldsRoutes(server); + visDataRoutes(server); + } + + + }); +} diff --git a/src/core_plugins/metrics/package.json b/src/core_plugins/metrics/package.json new file mode 100644 index 00000000000000..6b4874dfe6a68b --- /dev/null +++ b/src/core_plugins/metrics/package.json @@ -0,0 +1,6 @@ +{ + "author": "Chris Cowan", + "name": "metrics", + "version": "kibana" +} + diff --git a/src/core_plugins/metrics/public/components/__tests__/add_delete_buttons.js b/src/core_plugins/metrics/public/components/__tests__/add_delete_buttons.js new file mode 100644 index 00000000000000..b676e63673e074 --- /dev/null +++ b/src/core_plugins/metrics/public/components/__tests__/add_delete_buttons.js @@ -0,0 +1,67 @@ +// import React from 'react'; +// import { expect } from 'chai'; +// import { shallow } from 'enzyme'; +// import sinon from 'sinon'; +// import AddDeleteButtons from '../add_delete_buttons'; +// import Tooltip from '../tooltip'; + +// describe('', () => { + +// it('calls onAdd={handleAdd}', () => { +// const handleAdd = sinon.spy(); +// const wrapper = shallow( +// +// ); +// wrapper.find('a').at(0).simulate('click'); +// expect(handleAdd.calledOnce).to.equal(true); +// }); + +// it('calls onDelete={handleDelete}', () => { +// const handleDelete = sinon.spy(); +// const wrapper = shallow( +// +// ); +// wrapper.find('a').at(1).simulate('click'); +// expect(handleDelete.calledOnce).to.equal(true); +// }); + +// it('calls onClone={handleClone}', () => { +// const handleClone = sinon.spy(); +// const wrapper = shallow( +// +// ); +// wrapper.find('a').at(0).simulate('click'); +// expect(handleClone.calledOnce).to.equal(true); +// }); + +// it('disableDelete={true}', () => { +// const wrapper = shallow( +// +// ); +// expect(wrapper.find({ text: 'Delete' })).to.have.length(0); +// }); + +// it('disableAdd={true}', () => { +// const wrapper = shallow( +// +// ); +// expect(wrapper.find({ text: 'Add' })).to.have.length(0); +// }); + +// it('should not display clone by default', () => { +// const wrapper = shallow( +// +// ); +// expect(wrapper.find({ text: 'Clone' })).to.have.length(0); +// }); + +// it('should not display clone when disableAdd={true}', () => { +// const fn = sinon.spy(); +// const wrapper = shallow( +// +// ); +// expect(wrapper.find({ text: 'Clone' })).to.have.length(0); +// }); + +// }); + diff --git a/src/core_plugins/metrics/public/components/__tests__/yes_no.js b/src/core_plugins/metrics/public/components/__tests__/yes_no.js new file mode 100644 index 00000000000000..dd7413bbad20c1 --- /dev/null +++ b/src/core_plugins/metrics/public/components/__tests__/yes_no.js @@ -0,0 +1,33 @@ +// import React from 'react'; +// import { expect } from 'chai'; +// import { shallow } from 'enzyme'; +// import sinon from 'sinon'; +// import YesNo from '../yes_no'; + +// describe('', () => { + +// it('call onChange={handleChange} on yes', () => { +// const handleChange = sinon.spy(); +// const wrapper = shallow( +// +// ); +// wrapper.find('input').first().simulate('change'); +// expect(handleChange.calledOnce).to.equal(true); +// expect(handleChange.firstCall.args[0]).to.eql({ +// test: 1 +// }); +// }); + +// it('call onChange={handleChange} on no', () => { +// const handleChange = sinon.spy(); +// const wrapper = shallow( +// +// ); +// wrapper.find('input').last().simulate('change'); +// expect(handleChange.calledOnce).to.equal(true); +// expect(handleChange.firstCall.args[0]).to.eql({ +// test: 0 +// }); +// }); + +// }); diff --git a/src/core_plugins/metrics/public/components/add_delete_buttons.js b/src/core_plugins/metrics/public/components/add_delete_buttons.js new file mode 100644 index 00000000000000..d7804507bff52e --- /dev/null +++ b/src/core_plugins/metrics/public/components/add_delete_buttons.js @@ -0,0 +1,58 @@ +import React, { Component, PropTypes } from 'react'; +import Tooltip from './tooltip'; + +function AddDeleteButtons(props) { + const createDelete = () => { + if (props.disableDelete) { + return null; + } + return ( + + + + + + ); + }; + const createAdd = () => { + if (props.disableAdd) { + return null; + } + return ( + + + + + + ); + }; + const deleteBtn = createDelete(); + const addBtn = createAdd(); + let clone; + if (props.onClone && !props.disableAdd) { + clone = ( + + + + + + ); + } + return ( +
+ { clone } + { addBtn } + { deleteBtn } +
+ ); +} + +AddDeleteButtons.propTypes = { + disableAdd: PropTypes.bool, + disableDelete: PropTypes.bool, + onClone: PropTypes.func, + onAdd: PropTypes.func, + onDelete: PropTypes.func +}; + +export default AddDeleteButtons; diff --git a/src/core_plugins/metrics/public/components/aggs/agg.js b/src/core_plugins/metrics/public/components/aggs/agg.js new file mode 100644 index 00000000000000..9cd1c0a7537a4b --- /dev/null +++ b/src/core_plugins/metrics/public/components/aggs/agg.js @@ -0,0 +1,51 @@ +import React, { PropTypes } from 'react'; +import StdAgg from './std_agg'; +import aggToComponent from '../lib/agg_to_component'; +import { sortable } from 'react-anything-sortable'; + +function Agg(props) { + const { model } = props; + let Component = aggToComponent[model.type]; + if (!Component) { + Component = StdAgg; + } + const style = Object.assign({ cursor: 'default' }, props.style); + return ( +
+ +
+ ); + +} + +Agg.propTypes = { + disableDelete: PropTypes.bool, + fields: PropTypes.object, + model: PropTypes.object, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onDelete: PropTypes.func, + onMouseDown: PropTypes.func, + onSortableItemMount: PropTypes.func, + onSortableItemReadyToMove: PropTypes.func, + onTouchStart: PropTypes.func, + panel: PropTypes.object, + series: PropTypes.object, + siblings: PropTypes.array, + sortData: PropTypes.string, +}; + +export default sortable(Agg); diff --git a/src/core_plugins/metrics/public/components/aggs/agg_row.js b/src/core_plugins/metrics/public/components/aggs/agg_row.js new file mode 100644 index 00000000000000..c9a98eb93911cc --- /dev/null +++ b/src/core_plugins/metrics/public/components/aggs/agg_row.js @@ -0,0 +1,53 @@ +import React, { PropTypes } from 'react'; +import _ from 'lodash'; +import AddDeleteButtons from '../add_delete_buttons'; +import Tooltip from '../tooltip'; + +function AggRow(props) { + let iconClassName = 'fa fa-eye-slash'; + let iconRowClassName = 'vis_editor__agg_row-icon'; + const last = _.last(props.siblings); + if (last.id === props.model.id) { + iconClassName = 'fa fa-eye'; + iconRowClassName += ' last'; + } + + let dragHandle; + if (!props.disableDelete) { + dragHandle = ( +
+ +
+ +
+
+
+ ); + } + + return ( +
+
+
+ +
+ {props.children} + { dragHandle } + +
+
+ ); +} + +AggRow.propTypes = { + disableDelete: PropTypes.bool, + model: PropTypes.object, + onAdd: PropTypes.func, + onDelete: PropTypes.func, + siblings: PropTypes.array, +}; + +export default AggRow; diff --git a/src/core_plugins/metrics/public/components/aggs/agg_select.js b/src/core_plugins/metrics/public/components/aggs/agg_select.js new file mode 100644 index 00000000000000..8dcc21f3bd47be --- /dev/null +++ b/src/core_plugins/metrics/public/components/aggs/agg_select.js @@ -0,0 +1,26 @@ +import React, { PropTypes } from 'react'; +import Select from 'react-select'; +import { createOptions } from '../lib/agg_lookup'; + +function AggSelect(props) { + const { siblings, panelType } = props; + const options = createOptions(panelType, siblings); + return ( +
+ +
+ + + + ); + } + +} + +CalculationAgg.propTypes = { + disableDelete: PropTypes.bool, + fields: PropTypes.object, + model: PropTypes.object, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onDelete: PropTypes.func, + panel: PropTypes.object, + series: PropTypes.object, + siblings: PropTypes.array, +}; + +export default CalculationAgg; diff --git a/src/core_plugins/metrics/public/components/aggs/cumulative_sum.js b/src/core_plugins/metrics/public/components/aggs/cumulative_sum.js new file mode 100644 index 00000000000000..32760e79fb9743 --- /dev/null +++ b/src/core_plugins/metrics/public/components/aggs/cumulative_sum.js @@ -0,0 +1,52 @@ +import React, { PropTypes } from 'react'; +import AggRow from './agg_row'; +import AggSelect from './agg_select'; +import MetricSelect from './metric_select'; +import createChangeHandler from '../lib/create_change_handler'; +import createSelectHandler from '../lib/create_select_handler'; + +function CumlativeSumAgg(props) { + const { model, panel, siblings } = props; + const handleChange = createChangeHandler(props.onChange, model); + const handleSelectChange = createSelectHandler(handleChange); + return ( + +
+
Aggregation
+ +
+
+
Metric
+ +
+
+ ); + +} + +CumlativeSumAgg.propTypes = { + disableDelete: PropTypes.bool, + fields: PropTypes.object, + model: PropTypes.object, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onDelete: PropTypes.func, + panel: PropTypes.object, + series: PropTypes.object, + siblings: PropTypes.array, +}; + +export default CumlativeSumAgg; diff --git a/src/core_plugins/metrics/public/components/aggs/derivative.js b/src/core_plugins/metrics/public/components/aggs/derivative.js new file mode 100644 index 00000000000000..dd90257af1ce54 --- /dev/null +++ b/src/core_plugins/metrics/public/components/aggs/derivative.js @@ -0,0 +1,71 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import AggSelect from './agg_select'; +import MetricSelect from './metric_select'; +import AggRow from './agg_row'; +import createChangeHandler from '../lib/create_change_handler'; +import createSelectHandler from '../lib/create_select_handler'; +import createTextHandler from '../lib/create_text_handler'; + +class DerivativeAgg extends Component { + + render() { + const { siblings, panel } = this.props; + + const defaults = { unit: '' }; + const model = { ...defaults, ...this.props.model }; + + const handleChange = createChangeHandler(this.props.onChange, model); + const handleSelectChange = createSelectHandler(handleChange); + const handleTextChange = createTextHandler(handleChange); + + return ( + +
+
Aggregation
+ +
+
+
Metric
+ +
+
+
Units (1s, 1m, etc)
+ +
+
+ ); + } + +} + +DerivativeAgg.propTypes = { + disableDelete: PropTypes.bool, + fields: PropTypes.object, + model: PropTypes.object, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onDelete: PropTypes.func, + panel: PropTypes.object, + series: PropTypes.object, + siblings: PropTypes.array, +}; + +export default DerivativeAgg; diff --git a/src/core_plugins/metrics/public/components/aggs/field_select.js b/src/core_plugins/metrics/public/components/aggs/field_select.js new file mode 100644 index 00000000000000..2dd5272df3d55f --- /dev/null +++ b/src/core_plugins/metrics/public/components/aggs/field_select.js @@ -0,0 +1,44 @@ +import React, { PropTypes } from 'react'; +import _ from 'lodash'; +import Select from 'react-select'; +import AggLookup from '../lib/agg_lookup'; +import generateByTypeFilter from '../lib/generate_by_type_filter'; + +function FieldSelect(props) { + const { type, fields, indexPattern } = props; + if (type === 'count') { + return null; + } + const options = (fields[indexPattern] || []) + .filter(generateByTypeFilter(props.restrict)) + .map(field => { + return { label: field.name, value: field.name }; + }); + + return ( + + +
+
Denominator
+ +
+ +
+
+
Metric Aggregation
+ +
+ { model.metric_agg !== 'count' ? ( +
+
Field
+ +
) : null } +
+ + + ); + } + +} + +FilterRatioAgg.propTypes = { + disableDelete: PropTypes.bool, + fields: PropTypes.object, + model: PropTypes.object, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onDelete: PropTypes.func, + panel: PropTypes.object, + series: PropTypes.object, + siblings: PropTypes.array, +}; + +export default FilterRatioAgg; diff --git a/src/core_plugins/metrics/public/components/aggs/metric_select.js b/src/core_plugins/metrics/public/components/aggs/metric_select.js new file mode 100644 index 00000000000000..a953606ebbb07a --- /dev/null +++ b/src/core_plugins/metrics/public/components/aggs/metric_select.js @@ -0,0 +1,64 @@ +import React, { PropTypes } from 'react'; +import _ from 'lodash'; +import Select from 'react-select'; +import calculateSiblings from '../lib/calculate_siblings'; +import calculateLabel from '../lib/calculate_label'; +import basicAggs from '../lib/basic_aggs'; + +function createTypeFilter(restrict, exclude) { + return (metric) => { + if (_.includes(exclude, metric.type)) return false; + switch (restrict) { + case 'basic': + return _.includes(basicAggs, metric.type); + default: + return true; + } + }; +} + +function MetricSelect(props) { + const { + restrict, + metric, + onChange, + value, + exclude + } = props; + + const metrics = props.metrics + .filter(createTypeFilter(restrict, exclude)); + + const options = calculateSiblings(metrics, metric) + .filter(row => !/_bucket$/.test(row.type) && !/^series/.test(row.type)) + .map(row => { + const label = calculateLabel(row, metrics); + return { value: row.id, label }; + }); + + return ( + + +
+
Window Size
+ +
+
+
Minimize
+ +
+ + + + ); + } + +} + +MovingAverageAgg.propTypes = { + disableDelete: PropTypes.bool, + fields: PropTypes.object, + model: PropTypes.object, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onDelete: PropTypes.func, + panel: PropTypes.object, + series: PropTypes.object, + siblings: PropTypes.array, +}; + +export default MovingAverageAgg; diff --git a/src/core_plugins/metrics/public/components/aggs/percentile.js b/src/core_plugins/metrics/public/components/aggs/percentile.js new file mode 100644 index 00000000000000..d9b8eb3a8433db --- /dev/null +++ b/src/core_plugins/metrics/public/components/aggs/percentile.js @@ -0,0 +1,190 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import AggSelect from './agg_select'; +import FieldSelect from './field_select'; +import AggRow from './agg_row'; +import collectionActions from '../lib/collection_actions'; +import calculateSiblings from '../lib/calculate_siblings'; +import AddDeleteButtons from '../add_delete_buttons'; +import Select from 'react-select'; +import uuid from 'node-uuid'; +import createChangeHandler from '../lib/create_change_handler'; +import createSelectHandler from '../lib/create_select_handler'; +import createNumberHandler from '../lib/create_number_handler'; +const newPercentile = (opts) => { + return _.assign({ id: uuid.v1(), mode: 'line', shade: 0.2 }, opts); +}; + +class Percentiles extends Component { + + constructor(props) { + super(props); + this.renderRow = this.renderRow.bind(this); + } + + handleTextChange(item, name) { + return (e) => { + const handleChange = collectionActions.handleChange.bind(null, this.props); + const part = {}; + part[name] = _.get(e, 'value', _.get(e, 'target.value')); + handleChange(_.assign({}, item, part)); + }; + } + + handleNumberChange(item, name) { + return (e) => { + const handleChange = collectionActions.handleChange.bind(null, this.props); + const part = {}; + part[name] = Number(_.get(e, 'value', _.get(e, 'target.value'))); + handleChange(_.assign({}, item, part)); + }; + } + + renderRow(row, i, items) { + const defaults = { value: '', percentile: '', shade: '' }; + const model = { ...defaults, ...row }; + const handleAdd = collectionActions.handleAdd.bind(null, this.props, newPercentile); + const handleDelete = collectionActions.handleDelete.bind(null, this.props, model); + const modeOptions = [ + { label: 'Line', value: 'line' }, + { label: 'Band', value: 'band' } + ]; + const optionsStyle = {}; + if (model.mode === 'line') { + optionsStyle.display = 'none'; + } + return ( +
+
+ +
Mode
+
+ +
Shade (0 to 1)
+ +
+ +
+ ); + } + + render() { + const { model, name } = this.props; + if (!model[name]) return (
); + + const rows = model[name].map(this.renderRow); + return ( +
+ { rows } +
+ ); + } +} + +Percentiles.defaultProps = { + name: 'percentile' +}; + +Percentiles.propTypes = { + name: PropTypes.string, + model: PropTypes.object, + onChange: PropTypes.func +}; + + +class PercentileAgg extends Component { + + componentWillMount() { + if (!this.props.model.percentiles) { + this.props.onChange(_.assign({}, this.props.model, { + percentiles: [newPercentile({ value: 50 })] + })); + } + } + + render() { + const { series, model, panel, fields } = this.props; + + const handleChange = createChangeHandler(this.props.onChange, model); + const handleSelectChange = createSelectHandler(handleChange); + const handleNumberChange = createNumberHandler(handleChange); + const indexPattern = series.override_index_pattern && series.series_index_pattern || panel.index_pattern; + + return ( + +
+
+
+
Aggregation
+ +
+
+
Field
+ +
+
+ +
+
+ ); + } + +} + +PercentileAgg.propTypes = { + disableDelete: PropTypes.bool, + fields: PropTypes.object, + model: PropTypes.object, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onDelete: PropTypes.func, + panel: PropTypes.object, + series: PropTypes.object, + siblings: PropTypes.array, +}; + +export default PercentileAgg; + diff --git a/src/core_plugins/metrics/public/components/aggs/percentile_rank.js b/src/core_plugins/metrics/public/components/aggs/percentile_rank.js new file mode 100644 index 00000000000000..5d0394f74a59b8 --- /dev/null +++ b/src/core_plugins/metrics/public/components/aggs/percentile_rank.js @@ -0,0 +1,76 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import AggSelect from './agg_select'; +import FieldSelect from './field_select'; +import AggRow from './agg_row'; +import Select from 'react-select'; +import createChangeHandler from '../lib/create_change_handler'; +import createSelectHandler from '../lib/create_select_handler'; +import createTextHandler from '../lib/create_text_handler'; + +class PercentileRankAgg extends Component { + + render() { + const { series, panel, fields } = this.props; + const defaults = { value: '' }; + const model = { ...defaults, ...this.props.model }; + + + const handleChange = createChangeHandler(this.props.onChange, model); + const handleSelectChange = createSelectHandler(handleChange); + const handleTextChange = createTextHandler(handleChange); + + const indexPattern = series.override_index_pattern && series.series_index_pattern || panel.index_pattern; + + return ( + +
+
Aggregation
+ +
+
+
Field
+ +
+
+
Value
+ +
+
+ ); + } + +} + +PercentileRankAgg.propTypes = { + disableDelete: PropTypes.bool, + fields: PropTypes.object, + model: PropTypes.object, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onDelete: PropTypes.func, + panel: PropTypes.object, + series: PropTypes.object, + siblings: PropTypes.array, +}; + +export default PercentileRankAgg; + diff --git a/src/core_plugins/metrics/public/components/aggs/serial_diff.js b/src/core_plugins/metrics/public/components/aggs/serial_diff.js new file mode 100644 index 00000000000000..3f3af30e5954af --- /dev/null +++ b/src/core_plugins/metrics/public/components/aggs/serial_diff.js @@ -0,0 +1,71 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import AggSelect from './agg_select'; +import MetricSelect from './metric_select'; +import AggRow from './agg_row'; +import createChangeHandler from '../lib/create_change_handler'; +import createSelectHandler from '../lib/create_select_handler'; +import createNumberHandler from '../lib/create_number_handler'; + +class SerialDiffAgg extends Component { + + render() { + const { siblings, panel } = this.props; + const defaults = { lag: '' }; + const model = { ...defaults, ...this.props.model }; + + const handleChange = createChangeHandler(this.props.onChange, model); + const handleSelectChange = createSelectHandler(handleChange); + const handleNumberChange = createNumberHandler(handleChange); + + return ( + +
+
Aggregation
+ +
+
+
Metric
+ +
+
+
Lag
+ +
+
+ ); + } + +} + +SerialDiffAgg.propTypes = { + disableDelete: PropTypes.bool, + fields: PropTypes.object, + model: PropTypes.object, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onDelete: PropTypes.func, + panel: PropTypes.object, + series: PropTypes.object, + siblings: PropTypes.array, +}; + +export default SerialDiffAgg; + diff --git a/src/core_plugins/metrics/public/components/aggs/series_agg.js b/src/core_plugins/metrics/public/components/aggs/series_agg.js new file mode 100644 index 00000000000000..aef30554bc751e --- /dev/null +++ b/src/core_plugins/metrics/public/components/aggs/series_agg.js @@ -0,0 +1,66 @@ +import React, { PropTypes } from 'react'; +import _ from 'lodash'; +import AggSelect from './agg_select'; +import Select from 'react-select'; +import AggRow from './agg_row'; +import createChangeHandler from '../lib/create_change_handler'; +import createSelectHandler from '../lib/create_select_handler'; + +function SeriesAgg(props) { + const { model, panel, fields } = props; + + const handleChange = createChangeHandler(props.onChange, model); + const handleSelectChange = createSelectHandler(handleChange); + + const functionOptions = [ + { label: 'Sum', value: 'sum' }, + { label: 'Max', value: 'max' }, + { label: 'Min', value: 'min' }, + { label: 'Avg', value: 'mean' }, + { label: 'Overall Sum', value: 'overall_sum' }, + { label: 'Overall Max', value: 'overall_max' }, + { label: 'Overall Min', value: 'overall_min' }, + { label: 'Overall Avg', value: 'overall_avg' }, + { label: 'Cumlative Sum', value: 'cumlative_sum' }, + ]; + + return ( + +
+
Aggregation
+ +
+
+
Function
+ +
+
+
Mode
+ +
+ ); + + const modeOptions = [ + { label: 'Raw', value: 'raw' }, + { label: 'Upper Bound', value: 'upper' }, + { label: 'Lower Bound', value: 'lower' }, + { label: 'Bounds Band', value: 'band' } + ]; + + stdDev.mode = ( +
+
Mode
+ +
+
+ +
+
+ +
+
+ ); + } + + render() { + const { model, name } = this.props; + if (!model[name]) return (
); + const rows = model[name].map(this.renderRow); + return ( +
+ { rows } +
+ ); + } + +} + +CalculationVars.defaultProps = { + name: 'variables' +}; + +CalculationVars.propTypes = { + metrics: PropTypes.array, + model: PropTypes.object, + name: PropTypes.string, + onChange: PropTypes.func +}; + +export default CalculationVars; diff --git a/src/core_plugins/metrics/public/components/annotations_editor.js b/src/core_plugins/metrics/public/components/annotations_editor.js new file mode 100644 index 00000000000000..b4de4039cb9a2c --- /dev/null +++ b/src/core_plugins/metrics/public/components/annotations_editor.js @@ -0,0 +1,168 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import IndexPattern from './index_pattern'; +import collectionActions from './lib/collection_actions'; +import AddDeleteButtons from './add_delete_buttons'; +import ColorPicker from './color_picker'; +import FieldSelect from './aggs/field_select'; +import uuid from 'node-uuid'; +import IconSelect from './icon_select'; + +function newAnnotation() { + return { + id: uuid.v1(), + color: '#F00', + index_pattern: '*', + time_field: '@timestamp', + icon: 'fa-tag' + }; +} + +class AnnotationsEditor extends Component { + + constructor(props) { + super(props); + this.renderRow = this.renderRow.bind(this); + } + + handleChange(item, name) { + return (e) => { + const handleChange = collectionActions.handleChange.bind(null, this.props); + const part = {}; + part[name] = _.get(e, 'value', _.get(e, 'target.value')); + handleChange(_.assign({}, item, part)); + }; + } + + renderRow(row, i, items) { + const { fields } = this.props; + const defaults = { fields: '', template: '', index_pattern: '*', query_string: '' }; + const model = { ...defaults, ...row }; + const handleChange = (part) => { + const fn = collectionActions.handleChange.bind(null, this.props); + fn(_.assign({}, model, part)); + }; + const handleAdd = collectionActions.handleAdd + .bind(null, this.props, newAnnotation); + const handleDelete = collectionActions.handleDelete + .bind(null, this.props, model); + return ( +
+
+ +
+
+
+
+
Index Pattern (required)
+ +
+
+
Time Field (required)
+ +
+
+
+
+
Query String
+ +
+
+
+
+
Icon (required)
+
+ +
+
+
+
Fields (required - comma separated paths)
+ +
+
+
Row Template (required - eg.{'{{field}}'})
+ +
+
+
+
+ +
+
+ ); + } + + render() { + const { model } = this.props; + let content; + if (!model.annotations || !model.annotations.length) { + const handleAdd = collectionActions.handleAdd + .bind(null, this.props, newAnnotation); + content = ( +
+

Click the button below to create an annotation data source.

+ Add Data Source +
+ ); + } else { + const annotations = model.annotations.map(this.renderRow); + content = ( +
+
+
Data Sources
+
+ { annotations } +
+ ); + } + return( +
+ { content } +
+ ); + } + +} + +AnnotationsEditor.defaultProps = { + name: 'annotations' +}; + +AnnotationsEditor.propTypes = { + fields: PropTypes.object, + model: PropTypes.object, + name: PropTypes.string, + onChange: PropTypes.func +}; + +export default AnnotationsEditor; diff --git a/src/core_plugins/metrics/public/components/color_picker.js b/src/core_plugins/metrics/public/components/color_picker.js new file mode 100644 index 00000000000000..81b9ca854da604 --- /dev/null +++ b/src/core_plugins/metrics/public/components/color_picker.js @@ -0,0 +1,95 @@ +import React, { Component, PropTypes } from 'react'; +import Tooltip from './tooltip'; +import CustomColorPicker from './custom_color_picker'; +const Picker = CustomColorPicker; + +class ColorPicker extends Component { + + constructor(props) { + super(props); + this.state = { + displayPlicker: false, + color: {} + }; + + this.handleClick = this.handleClick.bind(this); + this.handleChange = this.handleChange.bind(this); + this.handleClear = this.handleClear.bind(this); + this.handleClose = this.handleClose.bind(this); + } + + handleChange(color) { + const { rgb, hex } = color; + const part = {}; + part[this.props.name] = `rgba(${rgb.r},${rgb.g},${rgb.b},${rgb.a})`; + if (this.props.onChange) this.props.onChange(part); + } + + handleClick() { + this.setState({ displayPicker: !this.state.displayColorPicker }); + } + + handleClose() { + this.setState({ displayPicker: false }); + } + + handleClear() { + const part = {}; + part[this.props.name] = null; + this.props.onChange(part); + } + + renderSwatch() { + if (!this.props.value) { + return ( +
+ ); + } + return ( +
+ ); + } + + render() { + const swatch = this.renderSwatch(); + const value = this.props.value || undefined; + let clear; + if (!this.props.disableTrash) { + clear = ( +
+ + + +
+ ); + } + return ( +
+ { swatch } + { clear } + { this.state.displayPicker ?
+
+ +
: null } +
+ ); + } + +} + +ColorPicker.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string, + disableTrash: PropTypes.bool, + onChange: PropTypes.func +}; + +export default ColorPicker; diff --git a/src/core_plugins/metrics/public/components/color_rules.js b/src/core_plugins/metrics/public/components/color_rules.js new file mode 100644 index 00000000000000..8ab4e0d058ef37 --- /dev/null +++ b/src/core_plugins/metrics/public/components/color_rules.js @@ -0,0 +1,115 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import AddDeleteButtons from './add_delete_buttons'; +import Select from 'react-select'; +import collectionActions from './lib/collection_actions'; +import ColorPicker from './color_picker'; + +class ColorRules extends Component { + + constructor(props) { + super(props); + this.renderRow = this.renderRow.bind(this); + } + + handleChange(item, name, cast = String) { + return (e) => { + const handleChange = collectionActions.handleChange.bind(null, this.props); + const part = {}; + part[name] = cast(_.get(e, 'value', _.get(e, 'target.value'))); + if (part[name] === 'undefined') part[name] = undefined; + handleChange(_.assign({}, item, part)); + }; + } + + renderRow(row, i, items) { + const defaults = { value: '' }; + const model = { ...defaults, ...row }; + const handleAdd = collectionActions.handleAdd.bind(null, this.props); + const handleDelete = collectionActions.handleDelete.bind(null, this.props, model); + const operatorOptions = [ + { label: '> greater then', value: 'gt' }, + { label: '>= greater then or equal', value: 'gte' }, + { label: '< less then', value: 'lt' }, + { label: '<= less then or equal', value: 'lte' }, + ]; + const handleColorChange = (part) => { + const handleChange = collectionActions.handleChange.bind(null, this.props); + handleChange(_.assign({}, model, part)); + }; + let secondary; + if (!this.props.hideSecondary) { + secondary = ( +
+
and {this.props.secondaryName} to
+ +
+ ); + } + return ( +
+
Set {this.props.primaryName} to
+ + { secondary } +
if metric is
+
+ +
+ +
+
+ ); + } + + render() { + const { model, name } = this.props; + if (!model[name]) return (
); + const rows = model[name].map(this.renderRow); + return ( +
+ { rows } +
+ ); + } + +} + +ColorRules.defaultProps = { + name: 'color_rules', + primaryName: 'background', + primaryVarName: 'background_color', + secondaryName: 'text', + secondaryVarName: 'color', + hideSecondary: false +}; + +ColorRules.propTypes = { + name: PropTypes.string, + model: PropTypes.object, + onChange: PropTypes.func, + primaryName: PropTypes.string, + primaryVarName: PropTypes.string, + secondaryName: PropTypes.string, + secondaryVarName: PropTypes.string, + hideSecondary: PropTypes.bool +}; + +export default ColorRules; diff --git a/src/core_plugins/metrics/public/components/custom_color_picker.js b/src/core_plugins/metrics/public/components/custom_color_picker.js new file mode 100644 index 00000000000000..bb5b4aa53557c4 --- /dev/null +++ b/src/core_plugins/metrics/public/components/custom_color_picker.js @@ -0,0 +1,131 @@ +import React, { Component, PropTypes } from 'react'; + +import { ColorWrap as colorWrap, Saturation, Hue, Alpha, Checkboard } from 'react-color/lib/components/common'; +import ChromeFields from 'react-color/lib/components/chrome/ChromeFields'; +import ChromePointer from 'react-color/lib/components/chrome/ChromePointer'; +import ChromePointerCircle from 'react-color/lib/components/chrome/ChromePointerCircle'; +import CompactColor from 'react-color/lib/components/compact/CompactColor'; +import color from 'react-color/lib/helpers/color'; +import shallowCompare from 'react-addons-shallow-compare'; + +export class CustomColorPicker extends Component { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + } + + shouldComponentUpdate(nextProps, nextState) { + return shallowCompare(nextProps, nextState); + } + + handleChange(data) { + this.props.onChange(data); + } + + render() { + const rgb = this.props.rgb; + + const styles = { + active: { + background: `rgba(${ rgb.r }, ${ rgb.g }, ${ rgb.b }, ${ rgb.a })`, + }, + Saturation: { + radius: '2px 2px 0 0 ' + }, + Hue: { + radius: '2px', + }, + Alpha: { + radius: '2px', + } + }; + + const handleSwatchChange = (data) => { + if (data.hex) { + color.isValidHex(data.hex) && this.props.onChange({ + hex: data.hex, + source: 'hex', + }); + } else { + this.props.onChange(data); + } + }; + + const swatches = this.props.colors.map((c) => { + return ( + + ); + }); + + return ( +
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+
+ +
+ {swatches} +
+
+
+ ); + } +} + +CustomColorPicker.defaultProps = { + colors: [ + '#4D4D4D', '#999999', '#FFFFFF', '#F44E3B', '#FE9200', '#FCDC00', + '#DBDF00', '#A4DD00', '#68CCCA', '#73D8FF', '#AEA1FF', '#FDA1FF', + '#333333', '#808080', '#cccccc', '#D33115', '#E27300', '#FCC400', + '#B0BC00', '#68BC00', '#16A5A5', '#009CE0', '#7B64FF', '#FA28FF', + '#0F1419', '#666666', '#B3B3B3', '#9F0500', '#C45100', '#FB9E00', + '#808900', '#194D33', '#0C797D', '#0062B1', '#653294', '#AB149E', + ], +}; + +CustomColorPicker.propTypes = { + color: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + onChangeComplete: PropTypes.func, + onChange: PropTypes.func +}; + +export default colorWrap(CustomColorPicker); diff --git a/src/core_plugins/metrics/public/components/data_format_picker.js b/src/core_plugins/metrics/public/components/data_format_picker.js new file mode 100644 index 00000000000000..c718d09f074983 --- /dev/null +++ b/src/core_plugins/metrics/public/components/data_format_picker.js @@ -0,0 +1,83 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import Select from 'react-select'; + +class DataFormatPicker extends Component { + + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.handleCustomChange = this.handleCustomChange.bind(this); + } + + handleCustomChange() { + this.props.onChange({ value: this.custom && this.custom.value || '' }); + } + + handleChange(value) { + if (value.value === 'custom') { + this.handleCustomChange(); + } else { + this.props.onChange(value); + } + } + + render() { + const value = this.props.value || ''; + let defaultValue = value; + if (!_.includes(['bytes', 'number', 'percent'], value)) { + defaultValue = 'custom'; + } + const options = [ + { label: 'Bytes', value: 'bytes' }, + { label: 'Number', value: 'number' }, + { label: 'Percent', value: 'percent' }, + { label: 'Custom', value: 'custom' } + ]; + + let custom; + if (defaultValue === 'custom') { + custom = ( +
+
+ Format String (See Numeral.js) +
+ this.custom = el} + onChange={this.handleCustomChange} + type="text"/> +
+ ); + } + return ( +
+
+ {this.props.label} +
+
+ + ); +} + +IconSelect.defaultProps = { + icons: [ + { value: 'fa-asterisk', label: 'Asterisk' }, + { value: 'fa-bell', label: 'Bell' }, + { value: 'fa-bolt', label: 'Bolt' }, + { value: 'fa-bomb', label: 'Bomb' }, + { value: 'fa-bug', label: 'Bug' }, + { value: 'fa-comment', label: 'Comment' }, + { value: 'fa-exclamation-circle', label: 'Exclamation Circle' }, + { value: 'fa-exclamation-triangle', label: 'Exclamation Triangle' }, + { value: 'fa-fire', label: 'Fire' }, + { value: 'fa-flag', label: 'Flag' }, + { value: 'fa-heart', label: 'Heart' }, + { value: 'fa-map-marker', label: 'Map Marker' }, + { value: 'fa-map-pin', label: 'Map Pin' }, + { value: 'fa-star', label: 'Star' }, + { value: 'fa-tag', label: 'Tag' }, + ] +}; + +IconSelect.propTypes = { + icons: PropTypes.array, + onChange: PropTypes.func, + value: PropTypes.string.isRequired +}; + +export default IconSelect; diff --git a/src/core_plugins/metrics/public/components/index_pattern.js b/src/core_plugins/metrics/public/components/index_pattern.js new file mode 100644 index 00000000000000..cbe132774fb18c --- /dev/null +++ b/src/core_plugins/metrics/public/components/index_pattern.js @@ -0,0 +1,66 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import FieldSelect from './aggs/field_select'; +import createSelectHandler from './lib/create_select_handler'; +import createTextHandler from './lib/create_text_handler'; + +class IndexPattern extends Component { + render() { + const { fields, prefix } = this.props; + const handleSelectChange = createSelectHandler(this.props.onChange); + const handleTextChange = createTextHandler(this.props.onChange); + const timeFieldName = `${prefix}time_field`; + const indexPatternName = `${prefix}index_pattern`; + const intervalName = `${prefix}interval`; + + const defaults = { + [indexPatternName]: '*', + [intervalName]: 'auto' + }; + + const model = { ...defaults, ...this.props.model }; + return ( +
+
Index Pattern
+ +
Time Field
+
+ +
+
Interval (auto, 1m, 1d, 1w, 1y)
+ +
+ ); + } +} + +IndexPattern.defaultProps = { + prefix: '', + disabled: false, + className: 'vis_editor__row' +}; + +IndexPattern.propTypes = { + model: PropTypes.object.isRequired, + fields: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + prefix: PropTypes.string, + disabled: PropTypes.bool, + className: PropTypes.string +}; + +export default IndexPattern; diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/agg_lookup.js b/src/core_plugins/metrics/public/components/lib/__tests__/agg_lookup.js new file mode 100644 index 00000000000000..bf5110aaabc564 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/agg_lookup.js @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import { createOptions, isBasicAgg } from '../agg_lookup'; + +describe('aggLookup', () => { + + describe('isBasicAgg(metric)', () => { + it('returns true for a basic metric (count)', () => { + expect(isBasicAgg({ type: 'count' })).to.equal(true); + }); + it('returns false for a pipeline metric (derivative)', () => { + expect(isBasicAgg({ type: 'derivative' })).to.equal(false); + }); + }); + + describe('createOptions(type, siblings)', () => { + + it('returns options for all aggs', () => { + const options = createOptions(); + expect(options).to.have.length(26); + options.forEach((option) => { + expect(option).to.have.property('label'); + expect(option).to.have.property('value'); + expect(option).to.have.property('disabled'); + }); + }); + + it('returns options for basic', () => { + const options = createOptions('basic'); + expect(options).to.have.length(13); + expect(options.every(opt => isBasicAgg({ type: opt.value }))).to.equal(true); + }); + + it('returns options for pipeline', () => { + const options = createOptions('pipeline'); + expect(options).to.have.length(13); + expect(options.every(opt => !isBasicAgg({ type: opt.value }))).to.equal(true); + }); + + it('returns options for all if given unknown key', () => { + const options = createOptions('foo'); + expect(options).to.have.length(26); + }); + + }); +}); diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/calculate_label.js b/src/core_plugins/metrics/public/components/lib/__tests__/calculate_label.js new file mode 100644 index 00000000000000..10414c15de61e5 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/calculate_label.js @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import calculateLabel from '../calculate_label'; + +describe('calculateLabel(metric, metrics)', () => { + it('returns "Unkonwn" for empty metric', () => { + expect(calculateLabel()).to.equal('Unknown'); + }); + + it('returns the metric.alias if set', () => { + expect(calculateLabel({ alias: 'Example' })).to.equal('Example'); + }); + + it('returns "Count" for a count metric', () => { + expect(calculateLabel({ type: 'count' })).to.equal('Count'); + }); + + it('returns "Calcuation" for a bucket script metric', () => { + expect(calculateLabel({ type: 'calculation' })).to.equal('Calculation'); + }); + + it('returns formated label for series_agg', () => { + const label = calculateLabel({ type: 'series_agg', function: 'max' }); + expect(label).to.equal('Series Agg (max)'); + }); + + it('returns formated label for basic aggs', () => { + const label = calculateLabel({ type: 'avg', field: 'memory' }); + expect(label).to.equal('Average of memory'); + }); + + it('returns formated label for pipeline aggs', () => { + const metric = { id: 2, type: 'derivative', field: 1 }; + const metrics = [ + { id: 1, type: 'max', field: 'network.out.bytes' }, + metric + ]; + const label = calculateLabel(metric, metrics); + expect(label).to.equal('Derivative of Max of network.out.bytes'); + }); + + it('returns formated label for pipeline aggs (deep)', () => { + const metric = { id: 3, type: 'derivative', field: 2 }; + const metrics = [ + { id: 1, type: 'max', field: 'network.out.bytes' }, + { id: 2, type: 'moving_average', field: 1 }, + metric + ]; + const label = calculateLabel(metric, metrics); + expect(label).to.equal('Derivative of Moving Average of Max of network.out.bytes'); + }); + + it('returns formated label for pipeline aggs uses alias for field metric', () => { + const metric = { id: 2, type: 'derivative', field: 1 }; + const metrics = [ + { id: 1, type: 'max', field: 'network.out.bytes', alias: 'Outbound Traffic' }, + metric + ]; + const label = calculateLabel(metric, metrics); + expect(label).to.equal('Derivative of Outbound Traffic'); + }); + +}); diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/calculate_siblings.js b/src/core_plugins/metrics/public/components/lib/__tests__/calculate_siblings.js new file mode 100644 index 00000000000000..0884cf0bc19354 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/calculate_siblings.js @@ -0,0 +1,19 @@ +import calculateSiblings from '../calculate_siblings'; +import { expect } from 'chai'; + +describe('calculateSiblings(metrics, metric)', () => { + it('should return all siblings', () => { + const metrics = [ + { id: 1, type: 'max', field: 'network.bytes' }, + { id: 2, type: 'derivative', field: 1 }, + { id: 3, type: 'derivative', field: 2 }, + { id: 4, type: 'moving_average', field: 2 }, + { id: 5, type: 'count' } + ]; + const siblings = calculateSiblings(metrics, { id: 2 }); + expect(siblings).to.eql([ + { id: 1, type: 'max', field: 'network.bytes' }, + { id: 5, type: 'count' } + ]); + }); +}); diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/collection_actions.js b/src/core_plugins/metrics/public/components/lib/__tests__/collection_actions.js new file mode 100644 index 00000000000000..d3329601f812cc --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/collection_actions.js @@ -0,0 +1,58 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import { + handleChange, + handleAdd, + handleDelete +} from '../collection_actions'; + +describe('collection actions', () => { + + it('handleChange() calls props.onChange() with updated collection', () => { + const fn = sinon.spy(); + const props = { + model: { test: [{ id: 1, title: 'foo' }] }, + name: 'test', + onChange: fn + }; + handleChange.call(null, props, { id: 1, title: 'bar' }); + expect(fn.calledOnce).to.equal(true); + expect(fn.firstCall.args[0]).to.eql({ + test: [{ id:1, title: 'bar' }] + }); + }); + + it('handleAdd() calls props.onChange() with update collection', () => { + const newItemFn = sinon.stub().returns({ id: 2, title: 'example' }); + const fn = sinon.spy(); + const props = { + model: { test: [{ id: 1, title: 'foo' }] }, + name: 'test', + onChange: fn + }; + handleAdd.call(null, props, newItemFn); + expect(fn.calledOnce).to.equal(true); + expect(newItemFn.calledOnce).to.equal(true); + expect(fn.firstCall.args[0]).to.eql({ + test: [{ id:1, title: 'foo' }, { id: 2, title: 'example' }] + }); + }); + + it('handleDelete() calls props.onChange() with update collection', () => { + const fn = sinon.spy(); + const props = { + model: { test: [{ id: 1, title: 'foo' }] }, + name: 'test', + onChange: fn + }; + handleDelete.call(null, props, { id: 1 }); + expect(fn.calledOnce).to.equal(true); + expect(fn.firstCall.args[0]).to.eql({ + test: [] + }); + }); + + + + +}); diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/convert_series_to_vars.js b/src/core_plugins/metrics/public/components/lib/__tests__/convert_series_to_vars.js new file mode 100644 index 00000000000000..0cf5d5df04d880 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/convert_series_to_vars.js @@ -0,0 +1,8 @@ +import convertSeriesToVars from '../convert_series_to_vars'; +import { expect } from 'chai'; + +describe('convertSeriesToVars(series, model)', () => { + it('returns and object', () => { + + }); +}); diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/create_number_handler.js b/src/core_plugins/metrics/public/components/lib/__tests__/create_number_handler.js new file mode 100644 index 00000000000000..4310c5d676427c --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/create_number_handler.js @@ -0,0 +1,27 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import createNumberHandler from '../create_number_handler'; + +describe('createNumberHandler()', () => { + + let handleChange; + let changeHandler; + let event; + + beforeEach(() => { + handleChange = sinon.spy(); + changeHandler = createNumberHandler(handleChange); + event = { preventDefault: sinon.spy(), target: { value: '1' } }; + const fn = changeHandler('test'); + fn(event); + }); + + it('calls handleChange() funciton with partial', () => { + expect(event.preventDefault.calledOnce).to.equal(true); + expect(handleChange.calledOnce).to.equal(true); + expect(handleChange.firstCall.args[0]).to.eql({ + test: 1 + }); + }); + +}); diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/create_select_handler.js b/src/core_plugins/metrics/public/components/lib/__tests__/create_select_handler.js new file mode 100644 index 00000000000000..283ec416d7b5a8 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/create_select_handler.js @@ -0,0 +1,26 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import createSelectHandler from '../create_select_handler'; + +describe('createSelectHandler()', () => { + + let handleChange; + let changeHandler; + let event; + + beforeEach(() => { + handleChange = sinon.spy(); + changeHandler = createSelectHandler(handleChange); + const fn = changeHandler('test'); + fn({ value: 'foo' }); + }); + + it('calls handleChange() funciton with partial', () => { + expect(handleChange.calledOnce).to.equal(true); + expect(handleChange.firstCall.args[0]).to.eql({ + test: 'foo' + }); + }); + +}); + diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/create_text_handler.js b/src/core_plugins/metrics/public/components/lib/__tests__/create_text_handler.js new file mode 100644 index 00000000000000..f4e45eb54ddb70 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/create_text_handler.js @@ -0,0 +1,28 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import createTextHandler from '../create_text_handler'; + +describe('createTextHandler()', () => { + + let handleChange; + let changeHandler; + let event; + + beforeEach(() => { + handleChange = sinon.spy(); + changeHandler = createTextHandler(handleChange); + event = { preventDefault: sinon.spy(), target: { value: 'foo' } }; + const fn = changeHandler('test'); + fn(event); + }); + + it('calls handleChange() funciton with partial', () => { + expect(event.preventDefault.calledOnce).to.equal(true); + expect(handleChange.calledOnce).to.equal(true); + expect(handleChange.firstCall.args[0]).to.eql({ + test: 'foo' + }); + }); + +}); + diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/generate_by_type_filter.js b/src/core_plugins/metrics/public/components/lib/__tests__/generate_by_type_filter.js new file mode 100644 index 00000000000000..da65b3150b918f --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/generate_by_type_filter.js @@ -0,0 +1,53 @@ +import generateByTypeFilter from '../generate_by_type_filter'; +import { expect } from 'chai'; + +describe('generateByTypeFilter()', () => { + + describe('numeric', () => { + const fn = generateByTypeFilter('numeric'); + [ + 'scaled_float', + 'half_float', + 'integer', + 'float', + 'long', + 'double' + ].forEach((type) => { + it(`should return true for ${type}`, () => expect(fn({ type })).to.equal(true)); + }); + }); + + describe('string', () => { + const fn = generateByTypeFilter('string'); + ['string', 'keyword', 'text'].forEach((type) => { + it(`should return true for ${type}`, () => expect(fn({ type })).to.equal(true)); + }); + }); + + describe('date', () => { + const fn = generateByTypeFilter('date'); + ['date'].forEach((type) => { + it(`should return true for ${type}`, () => expect(fn({ type })).to.equal(true)); + }); + }); + + describe('all', () => { + const fn = generateByTypeFilter('all'); + [ + 'scaled_float', + 'half_float', + 'integer', + 'float', + 'long', + 'double', + 'string', + 'text', + 'keyword', + 'date', + 'whatever' + ].forEach((type) => { + it(`should return true for ${type}`, () => expect(fn({ type })).to.equal(true)); + }); + }); + +}); diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/re_id_series.js b/src/core_plugins/metrics/public/components/lib/__tests__/re_id_series.js new file mode 100644 index 00000000000000..de211f85d49d9a --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/re_id_series.js @@ -0,0 +1,78 @@ +import uuid from 'node-uuid'; +import { expect } from 'chai'; +import reIdSeries from '../re_id_series'; + +describe('reIdSeries()', () => { + + it('reassign ids for series with just basic metrics', () => { + const series = { + id: uuid.v1(), + metrics: [ + { id: uuid.v1() }, + { id: uuid.v1() } + ] + }; + const newSeries = reIdSeries(series); + expect(newSeries).to.not.equal(series); + expect(newSeries.id).to.not.equal(series.id); + newSeries.metrics.forEach((val, key) => { + expect(val.id).to.not.equal(series.metrics[key].id); + }); + }); + + it('reassign ids for series with just basic metrics and group by', () => { + const firstMetricId = uuid.v1(); + const series = { + id: uuid.v1(), + metrics: [ + { id: firstMetricId }, + { id: uuid.v1() } + ], + terms_order_by: firstMetricId + }; + const newSeries = reIdSeries(series); + expect(newSeries).to.not.equal(series); + expect(newSeries.id).to.not.equal(series.id); + newSeries.metrics.forEach((val, key) => { + expect(val.id).to.not.equal(series.metrics[key].id); + }); + expect(newSeries.terms_order_by).to.equal(newSeries.metrics[0].id); + }); + + it('reassign ids for series with pipeline metrics', () => { + const firstMetricId = uuid.v1(); + const series = { + id: uuid.v1(), + metrics: [ + { id: firstMetricId }, + { id: uuid.v1(), field: firstMetricId } + ] + }; + const newSeries = reIdSeries(series); + expect(newSeries).to.not.equal(series); + expect(newSeries.id).to.not.equal(series.id); + expect(newSeries.metrics[0].id).to.equal(newSeries.metrics[1].field); + }); + + it('reassign ids for series with calculation vars', () => { + const firstMetricId = uuid.v1(); + const series = { + id: uuid.v1(), + metrics: [ + { id: firstMetricId }, + { + id: uuid.v1(), + type: 'calculation', + variables: [{ id: uuid.v1(), field: firstMetricId }] + } + ] + }; + const newSeries = reIdSeries(series); + expect(newSeries).to.not.equal(series); + expect(newSeries.id).to.not.equal(series.id); + expect(newSeries.metrics[1].variables[0].field).to.equal(newSeries.metrics[0].id); + }); + + + +}); diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/replace_vars.js b/src/core_plugins/metrics/public/components/lib/__tests__/replace_vars.js new file mode 100644 index 00000000000000..63d420d668a976 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/replace_vars.js @@ -0,0 +1,23 @@ +import { expect } from 'chai'; +import replaceVars from '../replace_vars'; + +describe('replaceVars(str, args, vars)', () => { + it('replaces vars with values', () => { + const vars = { total: 100 }; + const args = { host: 'test-01' }; + const template = '# {{args.host}} {{total}}'; + expect(replaceVars(template, args, vars)).to.equal('# test-01 100'); + }); + it('replaces args override vars', () => { + const vars = { total: 100, args: { test: 'foo-01' } }; + const args = { test: 'bar-01' }; + const template = '# {{args.test}} {{total}}'; + expect(replaceVars(template, args, vars)).to.equal('# bar-01 100'); + }); + it('returns original string if error', () => { + const vars = { total: 100 }; + const args = { host: 'test-01' }; + const template = '# {{args.host}} {{total'; + expect(replaceVars(template, args, vars)).to.equal('# {{args.host}} {{total'); + }); +}); diff --git a/src/core_plugins/metrics/public/components/lib/__tests__/tick_formatter.js b/src/core_plugins/metrics/public/components/lib/__tests__/tick_formatter.js new file mode 100644 index 00000000000000..bbfe1d7ccb7f4d --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/__tests__/tick_formatter.js @@ -0,0 +1,47 @@ +import { expect } from 'chai'; +import tickFormatter from '../tick_formatter'; + +describe('tickFormatter(format, template)', () => { + + it('returns a number with two decimal place by default', () => { + const fn = tickFormatter(); + expect(fn(1.5556)).to.equal('1.56'); + }); + + it('returns a percent with percent formatter', () => { + const fn = tickFormatter('percent'); + expect(fn(0.5556)).to.equal('55.56%'); + }); + + it('returns a byte formatted string with byte formatter', () => { + const fn = tickFormatter('bytes'); + expect(fn(1500 ^ 10)).to.equal('1.5KB'); + }); + + it('returns a custom forrmatted string with custom formatter', () => { + const fn = tickFormatter('0.0a'); + expect(fn(1500)).to.equal('1.5k'); + }); + + it('returns a custom forrmatted string with custom formatter and template', () => { + const fn = tickFormatter('0.0a', '{{value}}/s'); + expect(fn(1500)).to.equal('1.5k/s'); + }); + + it('returns zero if passed a string', () => { + const fn = tickFormatter(); + expect(fn('100')).to.equal('0'); + }); + + it('returns value if passed a bad formatter', () => { + const fn = tickFormatter('102'); + expect(fn(100)).to.equal('100'); + }); + + it('returns formatted value if passed a bad template', () => { + const fn = tickFormatter('number', '{{value'); + expect(fn(1.5556)).to.equal('1.56'); + }); + + +}); diff --git a/src/core_plugins/metrics/public/components/lib/agg_lookup.js b/src/core_plugins/metrics/public/components/lib/agg_lookup.js new file mode 100644 index 00000000000000..fb87655f89fd10 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/agg_lookup.js @@ -0,0 +1,78 @@ +import _ from 'lodash'; +const lookup = { + 'count': 'Count', + 'calculation': 'Calculation', + 'std_deviation': 'Std. Deviation', + 'variance': 'Variance', + 'sum_of_squares': 'Sum of Sq.', + 'avg': 'Average', + 'max': 'Max', + 'min': 'Min', + 'sum': 'Sum', + 'percentile': 'Percentile', + 'percentile_rank': 'Percentile Rank', + 'cardinality': 'Cardinality', + 'value_count': 'Value Count', + 'derivative': 'Derivative', + 'cumulative_sum': 'Cumulative Sum', + 'moving_average': 'Moving Average', + 'avg_bucket': 'Overall Average', + 'min_bucket': 'Overall Min', + 'max_bucket': 'Overall Max', + 'sum_bucket': 'Overall Sum', + 'variance_bucket': 'Overall Variance', + 'sum_of_squares_bucket': 'Overall Sum of Sq.', + 'std_deviation_bucket': 'Overall Std. Deviation', + 'series_agg': 'Series Agg', + 'serial_diff': 'Serial Difference', + 'filter_ratio': 'Filter Ratio' +}; + +const pipeline = [ + 'calculation', + 'derivative', + 'cumulative_sum', + 'moving_average', + 'avg_bucket', + 'min_bucket', + 'max_bucket', + 'sum_bucket', + 'variance_bucket', + 'sum_of_squares_bucket', + 'std_deviation_bucket', + 'series_agg', + 'serial_diff' +]; + +const byType = { + _all: lookup, + pipeline: pipeline, + basic: _.omit(lookup, pipeline), + metrics: _.pick(lookup, [ + 'count', + 'avg', + 'min', + 'max', + 'sum', + 'cardinality', + 'value_count' + ]) +}; + +export function isBasicAgg(item) { + return _.includes(Object.keys(byType.basic), item.type); +} + +export function createOptions(type = '_all', siblings = []) { + let aggs = byType[type]; + if (!aggs) aggs = byType._all; + return _(aggs) + .map((label, value) => { + const disabled = false; + return { label, value, disabled }; + }) + .sortBy('label') + .value(); +} +export default lookup; + diff --git a/src/core_plugins/metrics/public/components/lib/agg_to_component.js b/src/core_plugins/metrics/public/components/lib/agg_to_component.js new file mode 100644 index 00000000000000..df22a42f9782d0 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/agg_to_component.js @@ -0,0 +1,42 @@ +import MovingAverage from '../aggs/moving_average'; +import Derivative from '../aggs/derivative'; +import Calculation from '../aggs/calculation'; +import StdAgg from '../aggs/std_agg'; +import Percentile from '../aggs/percentile'; +import CumulativeSum from '../aggs/cumulative_sum'; +import StdDeviation from '../aggs/std_deviation'; +import StdSibling from '../aggs/std_sibling'; +import SeriesAgg from '../aggs/series_agg'; +import SerialDiff from '../aggs/serial_diff'; +import FilterRatio from '../aggs/filter_ratio'; +import PercentileRank from '../aggs/percentile_rank'; +export default { + count: StdAgg, + avg: StdAgg, + max: StdAgg, + min: StdAgg, + sum: StdAgg, + std_deviation: StdDeviation, + sum_of_squares: StdAgg, + variance: StdAgg, + avg_bucket: StdSibling, + max_bucket: StdSibling, + min_bucket: StdSibling, + sum_bucket: StdSibling, + variance_bucket: StdSibling, + sum_of_squares_bucket: StdSibling, + std_deviation_bucket: StdSibling, + percentile: Percentile, + percentile_rank: PercentileRank, + cardinality: StdAgg, + value_count: StdAgg, + calculation: Calculation, + cumulative_sum: CumulativeSum, + moving_average: MovingAverage, + derivative: Derivative, + series_agg: SeriesAgg, + serial_diff: SerialDiff, + filter_ratio: FilterRatio +}; + + diff --git a/src/core_plugins/metrics/public/components/lib/basic_aggs.js b/src/core_plugins/metrics/public/components/lib/basic_aggs.js new file mode 100644 index 00000000000000..9a5c12306f096b --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/basic_aggs.js @@ -0,0 +1,12 @@ +export default [ + 'count', + 'avg', + 'max', + 'min', + 'sum', + 'std_deviation', + 'variance', + 'sum_of_squares', + 'value_count', + 'cardinality' +]; diff --git a/src/core_plugins/metrics/public/components/lib/calculate_label.js b/src/core_plugins/metrics/public/components/lib/calculate_label.js new file mode 100644 index 00000000000000..e785c1aa968c7e --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/calculate_label.js @@ -0,0 +1,37 @@ +import _ from 'lodash'; +import lookup from './agg_lookup'; +const paths = [ + 'cumulative_sum', + 'derivative', + 'moving_average', + 'avg_bucket', + 'sum_bucket', + 'min_bucket', + 'max_bucket', + 'std_deviation_bucket', + 'variance_bucket', + 'sum_of_squares_bucket', + 'serial_diff' +]; +export default function calculateLabel(metric, metrics) { + if (!metric) return 'Unknown'; + if (metric.alias) return metric.alias; + + if (metric.type === 'count') return 'Count'; + if (metric.type === 'calculation') return 'Calculation'; + if (metric.type === 'series_agg') return `Series Agg (${metric.function})`; + if (metric.type === 'filter_ratio') return 'Filter Ratio'; + + if (metric.type === 'percentile_rank') { + return `${lookup[metric.type]} (${metric.value}) of ${metric.field}`; + } + + if (_.includes(paths, metric.type)) { + const targetMetric = _.find(metrics, { id: metric.field }); + const targetLabel = calculateLabel(targetMetric, metrics); + return `${lookup[metric.type]} of ${targetLabel}`; + } + + return `${lookup[metric.type]} of ${metric.field}`; +} + diff --git a/src/core_plugins/metrics/public/components/lib/calculate_siblings.js b/src/core_plugins/metrics/public/components/lib/calculate_siblings.js new file mode 100644 index 00000000000000..868bbe0c1bd767 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/calculate_siblings.js @@ -0,0 +1,17 @@ +import _ from 'lodash'; + +function getAncestors(siblings, item) { + const ancestors = item.id && [item.id] || []; + siblings.forEach((sib) => { + if (_.includes(ancestors, sib.field)) { + ancestors.push(sib.id); + } + }); + return ancestors; +} + +export default (siblings, model) => { + const ancestors = getAncestors(siblings, model); + return siblings.filter(row => !_.includes(ancestors, row.id)); +}; + diff --git a/src/core_plugins/metrics/public/components/lib/collection_actions.js b/src/core_plugins/metrics/public/components/lib/collection_actions.js new file mode 100644 index 00000000000000..f965fd4345100f --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/collection_actions.js @@ -0,0 +1,38 @@ +import uuid from 'node-uuid'; +import _ from 'lodash'; +export function handleChange(props, doc) { + const { model, name } = props; + const collection = model[name] || []; + const part = {}; + part[name] = collection.map(row => { + if (row.id === doc.id) return doc; + return row; + }); + if (_.isFunction(props.onChange)) { + props.onChange(_.assign({}, model, part)); + } +} + +export function handleDelete(props, doc) { + const { model, name } = props; + const collection = model[name] || []; + const part = {}; + part[name] = collection.filter(row => row.id !== doc.id); + if (_.isFunction(props.onChange)) { + props.onChange(_.assign({}, model, part)); + } +} + +const newFn = () => ({ id: uuid.v1() }); +export function handleAdd(props, fn = newFn) { + if (!_.isFunction(fn)) fn = newFn; + const { model, name } = props; + const collection = model[name] || []; + const part = {}; + part[name] = collection.concat([fn()]); + if (_.isFunction(props.onChange)) { + props.onChange(_.assign({}, model, part)); + } +} + +export default { handleAdd, handleDelete, handleChange }; diff --git a/src/core_plugins/metrics/public/components/lib/convert_series_to_vars.js b/src/core_plugins/metrics/public/components/lib/convert_series_to_vars.js new file mode 100644 index 00000000000000..740b33f4087a22 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/convert_series_to_vars.js @@ -0,0 +1,41 @@ +import _ from 'lodash'; +import getLastValue from '../../visualizations/lib/get_last_value'; +import tickFormatter from './tick_formatter'; +import moment from 'moment'; +import calculateLabel from './calculate_label'; +export default (series, model) => { + const variables = {}; + model.series.forEach(seriesModel => { + series + .filter(row => _.startsWith(row.id, seriesModel.id)) + .forEach(row => { + const metric = _.last(seriesModel.metrics); + + const varName = [ + _.snakeCase(row.label), + _.snakeCase(seriesModel.var_name) + ].filter(v => v).join('.'); + + const formatter = tickFormatter(seriesModel.formatter, seriesModel.value_template); + const lastValue = getLastValue(row.data, 10); + + const data = { + last: { + raw: lastValue, + formatted: formatter(lastValue) + }, + data: { + raw: row.data, + formatted: row.data.map(point => { + return [moment(point[0]).format('lll'), formatter(point[1])]; + }) + } + }; + _.set(variables, varName, data); + _.set(variables, `${_.snakeCase(row.label)}.label`, row.label); + + }); + }); + return variables; + +}; diff --git a/src/core_plugins/metrics/public/components/lib/create_agg_row_render.js b/src/core_plugins/metrics/public/components/lib/create_agg_row_render.js new file mode 100644 index 00000000000000..9594e32aa4c11e --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/create_agg_row_render.js @@ -0,0 +1,26 @@ +import React from 'react'; +import seriesChangeHandler from './series_change_handler'; +import newMetricAggFn from './new_metric_agg_fn'; +import { handleAdd, handleDelete } from './collection_actions'; +import Agg from '../aggs/agg'; + +export default function createAggRowRender(props) { + return (row, index, items) => { + const { panel, model, fields } = props; + const changeHandler = seriesChangeHandler(props, items); + return ( + + ); + }; +} diff --git a/src/core_plugins/metrics/public/components/lib/create_change_handler.js b/src/core_plugins/metrics/public/components/lib/create_change_handler.js new file mode 100644 index 00000000000000..f1d7711ce9ee0e --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/create_change_handler.js @@ -0,0 +1,5 @@ +import _ from 'lodash'; +export default (handleChange, model) => part => { + const doc = _.assign({}, model, part); + handleChange(doc); +}; diff --git a/src/core_plugins/metrics/public/components/lib/create_number_handler.js b/src/core_plugins/metrics/public/components/lib/create_number_handler.js new file mode 100644 index 00000000000000..d5f02a550547bd --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/create_number_handler.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; +export default (handleChange) => { + return (name, defaultValue) => (e) => { + e.preventDefault(); + const value = Number(_.get(e, 'target.value', defaultValue)); + if (_.isFunction(handleChange)) { + return handleChange({ [name]: value }); + } + }; +}; diff --git a/src/core_plugins/metrics/public/components/lib/create_select_handler.js b/src/core_plugins/metrics/public/components/lib/create_select_handler.js new file mode 100644 index 00000000000000..987d4957ad15d2 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/create_select_handler.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; +export default (handleChange) => { + return (name) => (value) => { + if (_.isFunction(handleChange)) { + return handleChange({ + [name]: value && value.value || null + }); + } + }; +}; diff --git a/src/core_plugins/metrics/public/components/lib/create_text_handler.js b/src/core_plugins/metrics/public/components/lib/create_text_handler.js new file mode 100644 index 00000000000000..eaa7202437328c --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/create_text_handler.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; +export default (handleChange) => { + return (name, defaultValue) => (e) => { + e.preventDefault(); + const value = _.get(e, 'target.value', defaultValue); + if (_.isFunction(handleChange)) { + return handleChange({ [name]: value }); + } + }; +}; diff --git a/src/core_plugins/metrics/public/components/lib/generate_by_type_filter.js b/src/core_plugins/metrics/public/components/lib/generate_by_type_filter.js new file mode 100644 index 00000000000000..91a44d1e61dd65 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/generate_by_type_filter.js @@ -0,0 +1,27 @@ +import _ from 'lodash'; +export default function byType(type) { + return (field) => { + switch (type) { + case 'numeric': + return _.includes([ + 'scaled_float', + 'half_float', + 'integer', + 'float', + 'long', + 'double' + ], field.type); + case 'string': + return _.includes([ + 'string', 'keyword', 'text' + ], field.type); + case 'date': + return _.includes([ + 'date' + ], field.type); + default: + return true; + } + }; +} + diff --git a/src/core_plugins/metrics/public/components/lib/new_metric_agg_fn.js b/src/core_plugins/metrics/public/components/lib/new_metric_agg_fn.js new file mode 100644 index 00000000000000..6c128939cb8ef0 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/new_metric_agg_fn.js @@ -0,0 +1,7 @@ +import uuid from 'node-uuid'; +export default () => { + return { + id: uuid.v1(), + type: 'count' + }; +}; diff --git a/src/core_plugins/metrics/public/components/lib/new_series_fn.js b/src/core_plugins/metrics/public/components/lib/new_series_fn.js new file mode 100644 index 00000000000000..bd1ffa62af9784 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/new_series_fn.js @@ -0,0 +1,19 @@ +import uuid from 'node-uuid'; +import _ from 'lodash'; +import newMetricAggFn from './new_metric_agg_fn'; +export default (obj = {}) => { + return _.assign({ + id: uuid.v1(), + color: '#68BC00', + split_mode: 'everything', + metrics: [ newMetricAggFn() ], + seperate_axis: 0, + axis_position: 'right', + formatter: 'number', + chart_type: 'line', + line_width: 1, + point_size: 1, + fill: 0, + stacked: 'none' + }, obj); +}; diff --git a/src/core_plugins/metrics/public/components/lib/re_id_series.js b/src/core_plugins/metrics/public/components/lib/re_id_series.js new file mode 100644 index 00000000000000..7d039493c4b493 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/re_id_series.js @@ -0,0 +1,22 @@ +import uuid from 'node-uuid'; +import _ from 'lodash'; +export default source => { + const series = _.cloneDeep(source); + series.id = uuid.v1(); + series.metrics.forEach((metric) => { + const id = uuid.v1(); + const metricId = metric.id; + metric.id = id; + if (series.terms_order_by === metricId) series.terms_order_by = id; + series.metrics.filter(r => r.field === metricId).forEach(r => r.field = id); + series.metrics.filter(r => r.type === 'calculation' && + r.variables.some(v => v.field === metricId)) + .forEach(r => { + r.variables.filter(v => v.field === metricId).forEach(v => { + v.id = uuid.v1(); + v.field = id; + }); + }); + }); + return series; +}; diff --git a/src/core_plugins/metrics/public/components/lib/replace_vars.js b/src/core_plugins/metrics/public/components/lib/replace_vars.js new file mode 100644 index 00000000000000..307059bcb95a9a --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/replace_vars.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; +import handlebars from 'handlebars/dist/handlebars'; +export default function replaceVars(str, args = {}, vars = {}) { + try { + const template = handlebars.compile(str); + return template(_.assign({}, vars, { args })); + } catch (e) { + return str; + } +} diff --git a/src/core_plugins/metrics/public/components/lib/series_change_handler.js b/src/core_plugins/metrics/public/components/lib/series_change_handler.js new file mode 100644 index 00000000000000..8542fa592ce259 --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/series_change_handler.js @@ -0,0 +1,23 @@ +import _ from 'lodash'; +import newMetricAggFn from './new_metric_agg_fn'; +import { isBasicAgg } from './agg_lookup'; +import { + handleAdd, + handleChange +} from './collection_actions'; +export default (props, items) => doc => { + // If we only have one sibling and the user changes to a pipeline + // agg we are going to add the pipeline instead of changing the + // current item. + if (items.length === 1 && !isBasicAgg(doc)) { + handleAdd.call(null, props, () => { + const metric = newMetricAggFn(); + metric.type = doc.type; + const incompatPipelines = ['calculation', 'series_agg']; + if (!_.contains(incompatPipelines, doc.type)) metric.field = doc.id; + return metric; + }); + } else { + handleChange.call(null, props, doc); + } +}; diff --git a/src/core_plugins/metrics/public/components/lib/tick_formatter.js b/src/core_plugins/metrics/public/components/lib/tick_formatter.js new file mode 100644 index 00000000000000..5508f162f4c63c --- /dev/null +++ b/src/core_plugins/metrics/public/components/lib/tick_formatter.js @@ -0,0 +1,32 @@ +import numeral from '@spalger/numeral'; +import _ from 'lodash'; +import handlebars from 'handlebars/dist/handlebars'; + +const formatLookup = { + 'bytes': '0.0b', + 'number': '0,0.[00]', + 'percent': '0.[00]%' +}; + +export default (format = '0,0.[00]', template) => { + if (!template) template = '{{value}}'; + const render = handlebars.compile(template); + return (val) => { + const formatString = formatLookup[format] || format; + let value; + if (!_.isNumber(val)) { + value = 0; + } else { + try { + value = numeral(val).format(formatString); + } catch (e) { + value = val; + } + } + try { + return render({ value }); + } catch (e) { + return String(value); + } + }; +}; diff --git a/src/core_plugins/metrics/public/components/markdown_editor.js b/src/core_plugins/metrics/public/components/markdown_editor.js new file mode 100644 index 00000000000000..b77efd24d925a9 --- /dev/null +++ b/src/core_plugins/metrics/public/components/markdown_editor.js @@ -0,0 +1,144 @@ +/* eslint max-len:0 */ +import React, { Component, PropTypes } from 'react'; +import tickFormatter from './lib/tick_formatter'; +import moment from 'moment'; +import calculateLabel from './lib/calculate_label'; +import convertSeriesToVars from './lib/convert_series_to_vars'; +import AceEditor from 'react-ace'; +import _ from 'lodash'; +import brace from 'brace'; +import 'brace/mode/markdown'; +import 'brace/theme/github'; +import { getLastValue } from 'plugins/metrics/visualizations'; +import numeral from 'numeral'; + +class MarkdownEditor extends Component { + + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.handleOnLoad = this.handleOnLoad.bind(this); + } + + handleChange(value) { + this.props.onChange({ markdown: value }); + } + + handleOnLoad(ace) { + this.ace = ace; + } + + handleVarClick(snippet) { + return (e) => { + if (this.ace) this.ace.insert(snippet); + }; + } + + render() { + const { model, visData } = this.props; + const series = _.get(visData, `${model.id}.series`, []); + const variables = convertSeriesToVars(series, model); + const rows = []; + const rawFormatter = tickFormatter('0.[0000]'); + + const createPrimativeRow = key => { + const snippet = `{{ ${key} }}`; + let value = _.get(variables, key); + if (/raw$/.test(key)) value = rawFormatter(value); + rows.push( + + + + { snippet } + + + + "{ value }" + + + ); + }; + + const createArrayRow = key => { + const snippet = `{{# ${key} }}{{/ ${key} }}`; + const date = _.get(variables, `${key}[0][0]`); + let value = _.get(variables, `${key}[0][1]`); + if (/raw$/.test(key)) value = rawFormatter(value); + rows.push( + + + + { `{{ ${key} }}` } + + + + [ [ "{date}", "{value}" ], ... ] + + + ); + }; + + function walk(obj, path = []) { + for (const name in obj) { + if (_.isArray(obj[name])) { + createArrayRow(path.concat(name).join('.')); + } else if (_.isObject(obj[name])) { + walk(obj[name], path.concat(name)); + } else { + createPrimativeRow(path.concat(name).join('.')); + } + } + } + + walk(variables); + + + return ( +
+
+ +
+
+
The following variables can be used in the Markdown by using the Handlebar (mustache) syntax. Click here for documentation on the available expressions. HTML is also enabled.
+ + + + + + + + + {rows} + +
NameValue
+
There is also a special variable named _all which you can use to access the entire tree. This is useful for creating lists with data from a group by...
+
+            {`# All servers:
+
+{{#each _all}}
+- {{ label }} {{ last.formatted }}
+{{/each}}`}
+          
+
+
+ ); + } + +} + +MarkdownEditor.propTypes = { + onChange: PropTypes.func, + model: PropTypes.object, + visData: PropTypes.object +}; + +export default MarkdownEditor; diff --git a/src/core_plugins/metrics/public/components/panel_config.js b/src/core_plugins/metrics/public/components/panel_config.js new file mode 100644 index 00000000000000..26aa7fda0ed9b4 --- /dev/null +++ b/src/core_plugins/metrics/public/components/panel_config.js @@ -0,0 +1,32 @@ +import React, { PropTypes } from 'react'; +import timeseries from './panel_config/timeseries'; +import metric from './panel_config/metric'; +import topN from './panel_config/top_n'; +import gauge from './panel_config/gauge'; +import markdown from './panel_config/markdown'; + +const types = { + timeseries, + metric, + top_n: topN, + gauge, + markdown +}; + +function PanelConfig(props) { + const { model } = props; + const component = types[model.type]; + if (component) { + return React.createElement(component, props); + } + return (
Missing panel config for "{model.type}"
); +} + +PanelConfig.propTypes = { + fields: PropTypes.object, + model: PropTypes.object, + onChange: PropTypes.func, + visData: PropTypes.object, +}; + +export default PanelConfig; diff --git a/src/core_plugins/metrics/public/components/panel_config/gauge.js b/src/core_plugins/metrics/public/components/panel_config/gauge.js new file mode 100644 index 00000000000000..847f40fdb4d914 --- /dev/null +++ b/src/core_plugins/metrics/public/components/panel_config/gauge.js @@ -0,0 +1,168 @@ +import React, { Component, PropTypes } from 'react'; +import SeriesEditor from '../series_editor'; +import IndexPattern from '../index_pattern'; +import Select from 'react-select'; +import createSelectHandler from '../lib/create_select_handler'; +import createTextHandler from '../lib/create_text_handler'; +import createNumberHandler from '../lib/create_number_handler'; +import DataFormatPicker from '../data_format_picker'; +import ColorRules from '../color_rules'; +import ColorPicker from '../color_picker'; +import uuid from 'node-uuid'; +import YesNo from 'plugins/metrics/components/yes_no'; + +class GaugePanelConfig extends Component { + + constructor(props) { + super(props); + this.state = { selectedTab: 'data' }; + } + + componentWillMount() { + const { model } = this.props; + const parts = {}; + if (!model.gauge_color_rules || + (model.gauge_color_rules && model.gauge_color_rules.length === 0)) { + parts.gauge_color_rules = [{ id: uuid.v1() }]; + } + if (model.gauge_width == null) parts.gauge_width = 10; + if (model.gauge_inner_width == null) parts.gauge_inner_width = 10; + if (model.gauge_style == null) parts.gauge_style = 'half'; + this.props.onChange(parts); + } + + switchTab(selectedTab) { + this.setState({ selectedTab }); + } + + render() { + const { selectedTab } = this.state; + const defaults = { + gauge_max: '', + filter: '', + gauge_style: 'circle', + gauge_inner_width: '', + gauge_width: '' + }; + const model = { ...defaults, ...this.props.model }; + const handleSelectChange = createSelectHandler(this.props.onChange); + const handleTextChange = createTextHandler(this.props.onChange); + const handleNumberChange = createNumberHandler(this.props.onChange); + const positionOptions = [ + { label: 'Right', value: 'right' }, + { label: 'Left', value: 'left' } + ]; + const styleOptions = [ + { label: 'Circle', value: 'circle' }, + { label: 'Half Circle', value: 'half' } + ]; + let view; + if (selectedTab === 'data') { + view = ( + + ); + } else { + view = ( +
+ +
+
Panel Filter
+ +
Ignore Global Filter
+ +
+
+
Background Color
+ +
Gauge Max (empty for auto)
+ +
Gauge Style
+ +
Gauge Line Width
+ +
+
+
Color Rules
+
+
+ +
+
+ ); + } + return ( +
+
+
this.switchTab('data')}>Data
+
this.switchTab('options')}>Panel Options
+
+ {view} +
+ ); + } + +} + +GaugePanelConfig.propTypes = { + fields: PropTypes.object, + model: PropTypes.object, + onChange: PropTypes.func, + visData: PropTypes.object, +}; + +export default GaugePanelConfig; diff --git a/src/core_plugins/metrics/public/components/panel_config/markdown.js b/src/core_plugins/metrics/public/components/panel_config/markdown.js new file mode 100644 index 00000000000000..3497ace42bd834 --- /dev/null +++ b/src/core_plugins/metrics/public/components/panel_config/markdown.js @@ -0,0 +1,157 @@ +import React, { Component, PropTypes } from 'react'; +import SeriesEditor from '../series_editor'; +import IndexPattern from '../index_pattern'; +import AceEditor from 'react-ace'; +import brace from 'brace'; +import 'brace/mode/less'; +import Select from 'react-select'; +import createSelectHandler from '../lib/create_select_handler'; +import createTextHandler from '../lib/create_text_handler'; +import DataFormatPicker from '../data_format_picker'; +import ColorPicker from '../color_picker'; +import YesNo from '../yes_no'; +import MarkdownEditor from '../markdown_editor'; +import less from 'less/lib/less-browser'; +const lessC = less(window, { env: 'production' }); + +class MarkdownPanelConfig extends Component { + + constructor(props) { + super(props); + this.state = { selectedTab: 'markdown' }; + this.handleCSSChange = this.handleCSSChange.bind(this); + } + + switchTab(selectedTab) { + this.setState({ selectedTab }); + } + + handleCSSChange(value) { + const { model } = this.props; + const lessSrc = `#markdown-${model.id} { + ${value} +}`; + lessC.render(lessSrc, { compress: true }, (e, output) => { + const parts = { markdown_less: value }; + if (output) { + parts.markdown_css = output.css; + } + this.props.onChange(parts); + }); + } + + render() { + const defaults = { filter: '' }; + const model = { ...defaults, ...this.props.model }; + const { selectedTab } = this.state; + const handleSelectChange = createSelectHandler(this.props.onChange); + const handleTextChange = createTextHandler(this.props.onChange); + const positionOptions = [ + { label: 'Right', value: 'right' }, + { label: 'Left', value: 'left' } + ]; + + const legendPositionOptions = [ + { label: 'Right', value: 'right' }, + { label: 'Left', value: 'left' }, + { label: 'Bottom', value: 'bottom' } + ]; + + const alignOptions = [ + { label: 'Top', value: 'top' }, + { label: 'Middle', value: 'middle' }, + { label: 'Bottom', value: 'bottom' } + ]; + let view; + if (selectedTab === 'markdown') { + view = (); + } else if (selectedTab === 'data') { + view = ( + + ); + } else { + view = ( +
+ +
+
Background Color
+ +
Panel Filter
+ +
Ignore Global Filter
+ +
+
+
Show Scrollbars
+ +
Vertical Alignment
+
+ +
Ignore Global Filter
+ +
+
+
Color Rules
+
+
+ +
+
+ ); + } + return ( +
+
+
this.switchTab('data')}>Data
+
this.switchTab('options')}>Panel Options
+
+ {view} +
+ ); + } + +} + +MetricPanelConfig.propTypes = { + fields: PropTypes.object, + model: PropTypes.object, + onChange: PropTypes.func, + visData: PropTypes.object, +}; + +export default MetricPanelConfig; diff --git a/src/core_plugins/metrics/public/components/panel_config/timeseries.js b/src/core_plugins/metrics/public/components/panel_config/timeseries.js new file mode 100644 index 00000000000000..f0efbbe96b9f7c --- /dev/null +++ b/src/core_plugins/metrics/public/components/panel_config/timeseries.js @@ -0,0 +1,151 @@ +import React, { Component, PropTypes } from 'react'; +import SeriesEditor from '../series_editor'; +import AnnotationsEditor from '../annotations_editor'; +import IndexPattern from '../index_pattern'; +import Select from 'react-select'; +import createSelectHandler from '../lib/create_select_handler'; +import createTextHandler from '../lib/create_text_handler'; +import DataFormatPicker from '../data_format_picker'; +import ColorPicker from '../color_picker'; +import YesNo from '../yes_no'; + +class TimeseriesPanelConfig extends Component { + + constructor(props) { + super(props); + this.state = { selectedTab: 'data' }; + } + + switchTab(selectedTab) { + this.setState({ selectedTab }); + } + + render() { + const defaults = { + filter: '', + axis_max: '', + axis_min: '', + legend_position: 'right' + }; + const model = { ...defaults, ...this.props.model }; + const { selectedTab } = this.state; + const handleSelectChange = createSelectHandler(this.props.onChange); + const handleTextChange = createTextHandler(this.props.onChange); + const positionOptions = [ + { label: 'Right', value: 'right' }, + { label: 'Left', value: 'left' } + ]; + const legendPositionOptions = [ + { label: 'Right', value: 'right' }, + { label: 'Left', value: 'left' }, + { label: 'Bottom', value: 'bottom' } + ]; + let view; + if (selectedTab === 'data') { + view = ( + + ); + } else if (selectedTab === 'annotations') { + view = ( + + ); + } else { + view = ( +
+ +
+
Axis Min
+ +
Axis Max
+ +
Axis Position
+
+ +
+
+
+
Panel Filter
+ +
Ignore Global Filter
+ +
+
+ ); + } + return ( +
+
+
this.switchTab('data')}>Data
+
this.switchTab('options')}>Panel Options
+
this.switchTab('annotations')}>Annotations
+
+ {view} +
+ ); + } + + +} + +TimeseriesPanelConfig.propTypes = { + fields: PropTypes.object, + model: PropTypes.object, + onChange: PropTypes.func, + visData: PropTypes.object, +}; + +export default TimeseriesPanelConfig; diff --git a/src/core_plugins/metrics/public/components/panel_config/top_n.js b/src/core_plugins/metrics/public/components/panel_config/top_n.js new file mode 100644 index 00000000000000..c28cbf6f2d3797 --- /dev/null +++ b/src/core_plugins/metrics/public/components/panel_config/top_n.js @@ -0,0 +1,129 @@ +import React, { Component, PropTypes } from 'react'; +import SeriesEditor from '../series_editor'; +import _ from 'lodash'; +import IndexPattern from '../index_pattern'; +import Select from 'react-select'; +import createSelectHandler from '../lib/create_select_handler'; +import createTextHandler from '../lib/create_text_handler'; +import DataFormatPicker from '../data_format_picker'; +import ColorRules from '../color_rules'; +import ColorPicker from '../color_picker'; +import uuid from 'node-uuid'; +import YesNo from '../yes_no'; + +class TopNPanelConfig extends Component { + + constructor(props) { + super(props); + this.state = { selectedTab: 'data' }; + } + + componentWillMount() { + const { model } = this.props; + const parts = {}; + if (!model.bar_color_rules || (model.bar_color_rules && model.bar_color_rules.length === 0)) { + parts.bar_color_rules = [{ id: uuid.v1() }]; + } + if (model.series && model.series.length > 0) { + parts.series = [_.assign({}, model.series[0])]; + } + this.props.onChange(parts); + } + + switchTab(selectedTab) { + this.setState({ selectedTab }); + } + + render() { + const { selectedTab } = this.state; + const { fields } = this.props; + const defaults = { drilldown_url: '', filter: '' }; + const model = { ...defaults, ...this.props.model }; + const handleSelectChange = createSelectHandler(this.props.onChange); + const handleTextChange = createTextHandler(this.props.onChange); + const positionOptions = [ + { label: 'Right', value: 'right' }, + { label: 'Left', value: 'left' } + ]; + let view; + if (selectedTab === 'data') { + view = ( + + ); + } else { + view = ( +
+
+
Item Url (This supports mustache templating. + {'{{key}}'} is set to the term)
+ +
+ +
+
Background Color
+ +
Panel Filter
+ +
Ignore Global Filter
+ +
+
+
Color Rules
+
+
+ +
+
+ ); + } + return ( +
+
+
this.switchTab('data')}>Data
+
this.switchTab('options')}>Panel Options
+
+ {view} +
+ ); + } + +} + +TopNPanelConfig.propTypes = { + fields: PropTypes.object, + model: PropTypes.object, + onChange: PropTypes.func, + visData: PropTypes.object, +}; + +export default TopNPanelConfig; diff --git a/src/core_plugins/metrics/public/components/series.js b/src/core_plugins/metrics/public/components/series.js new file mode 100644 index 00000000000000..6111b2560a5257 --- /dev/null +++ b/src/core_plugins/metrics/public/components/series.js @@ -0,0 +1,108 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; + +import timeseries from './vis_types/timeseries/series'; +import metric from './vis_types/metric/series'; +import topN from './vis_types/top_n/series'; +import gauge from './vis_types/gauge/series'; +import markdown from './vis_types/markdown/series'; +import { sortable } from 'react-anything-sortable'; + +const lookup = { + top_n: topN, + metric, + timeseries, + gauge, + markdown +}; + +class Series extends Component { + + constructor(props) { + super(props); + this.state = { + visible: true, + selectedTab: 'metrics' + }; + this.handleChange = this.handleChange.bind(this); + this.switchTab = this.switchTab.bind(this); + this.toggleVisible = this.toggleVisible.bind(this); + } + + switchTab(selectedTab) { + this.setState({ selectedTab }); + } + + handleChange(part) { + if (this.props.onChange) { + const { model } = this.props; + const doc = _.assign({}, model, part); + this.props.onChange(doc); + } + } + + toggleVisible(e) { + e.preventDefault(); + this.setState({ visible: !this.state.visible }); + } + + render() { + const { panel } = this.props; + const Component = lookup[panel.type]; + if (Component) { + const params = { + className: this.props.className, + colorPicker: this.props.colorPicker, + disableAdd: this.props.disableAdd, + disableDelete: this.props.disableDelete, + fields: this.props.fields, + name: this.props.name, + onAdd: this.props.onAdd, + onChange: this.handleChange, + onClone: this.props.onClone, + onDelete: this.props.onDelete, + onMouseDown: this.props.onMouseDown, + onTouchStart: this.props.onTouchStart, + onSortableItemMount: this.props.onSortableItemMount, + onSortableItemReadyToMove: this.props.onSortableItemReadyToMove, + model: this.props.model, + panel: this.props.panel, + selectedTab: this.state.selectedTab, + sortData: this.props.sortData, + style: this.props.style, + switchTab: this.switchTab, + toggleVisible: this.toggleVisible, + visible: this.state.visible + }; + return (); + } + return (
Missing Series component for panel type: {panel.type}
); + } + +} + +Series.defaultProps = { + name: 'metrics' +}; + +Series.propTypes = { + className: PropTypes.string, + colorPicker: PropTypes.bool, + disableAdd: PropTypes.bool, + disableDelete: PropTypes.bool, + fields: PropTypes.object, + name: PropTypes.string, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onClone: PropTypes.func, + onDelete: PropTypes.func, + onMouseDown: PropTypes.func, + onSortableItemMount: PropTypes.func, + onSortableItemReadyToMove: PropTypes.func, + onTouchStart: PropTypes.func, + model: PropTypes.object, + panel: PropTypes.object, + sortData: PropTypes.string, +}; + +export default sortable(Series); diff --git a/src/core_plugins/metrics/public/components/series_config.js b/src/core_plugins/metrics/public/components/series_config.js new file mode 100644 index 00000000000000..319fb7c77139c1 --- /dev/null +++ b/src/core_plugins/metrics/public/components/series_config.js @@ -0,0 +1,64 @@ +import React, { Component, PropTypes } from 'react'; +import Select from 'react-select'; +import DataFormatPicker from './data_format_picker'; +import createSelectHandler from './lib/create_select_handler'; +import createTextHandler from './lib/create_text_handler'; +import YesNo from './yes_no'; +import IndexPattern from './index_pattern'; + +class SeriesConfig extends Component { + render() { + const { fields } = this.props; + const defaults = { offset_time: '', value_template: '' }; + const model = { ...defaults, ...this.props.model }; + const handleSelectChange = createSelectHandler(this.props.onChange); + const handleTextChange = createTextHandler(this.props.onChange); + + return ( +
+
+
+ +
Template (eg.{'{{value}}/s'})
+ +
Offset series time by (1m, 1h, 1w, 1d)
+ +
+
+
Override Index Pattern
+ + +
+
+
+ ); + } + +} + +SeriesConfig.propTypes = { + fields: PropTypes.object, + model: PropTypes.object, + onChange: PropTypes.func +}; + +export default SeriesConfig; + diff --git a/src/core_plugins/metrics/public/components/series_editor.js b/src/core_plugins/metrics/public/components/series_editor.js new file mode 100644 index 00000000000000..db603c360f2380 --- /dev/null +++ b/src/core_plugins/metrics/public/components/series_editor.js @@ -0,0 +1,83 @@ +import React, { Component, PropTypes } from 'react'; +import reIdSeries from './lib/re_id_series'; +import _ from 'lodash'; +import Series from './series'; +import { + handleAdd, + handleDelete, + handleChange +} from './lib/collection_actions'; +import newSeriesFn from './lib/new_series_fn'; +import Sortable from 'react-anything-sortable'; + +class SeriesEditor extends Component { + + constructor(props) { + super(props); + this.renderRow = this.renderRow.bind(this); + } + + handleClone(series) { + const newSeries = reIdSeries(series); + handleAdd.call(null, this.props, () => newSeries); + } + + renderRow(row, index) { + const { props } = this; + const { fields, model, name, limit, colorPicker } = props; + return ( + = limit} + disableDelete={model[name].length < 2} + fields={fields} + key={row.id} + onAdd={handleAdd.bind(null, props, newSeriesFn)} + onChange={handleChange.bind(null, props)} + onClone={() => this.handleClone(row)} + onDelete={handleDelete.bind(null, props, row)} + model={row} + panel={model} + sortData={row.id} /> + ); + } + + render() { + const { limit, model, name } = this.props; + const series = model[name] + .filter((val, index) => index < (limit || Infinity)) + .map(this.renderRow); + const handleSort = (data) => { + const series = data.map(id => model[name].find(s => s.id === id)); + this.props.onChange({ series }); + }; + return ( +
+ + { series } + +
+ ); + } + +} +SeriesEditor.defaultProps = { + name: 'series', + limit: Infinity, + colorPicker: true +}; + +SeriesEditor.propTypes = { + colorPicker: PropTypes.bool, + fields: PropTypes.object, + limit: PropTypes.number, + model: PropTypes.object, + name: PropTypes.string, + onChange: PropTypes.func +}; + +export default SeriesEditor; diff --git a/src/core_plugins/metrics/public/components/split.js b/src/core_plugins/metrics/public/components/split.js new file mode 100644 index 00000000000000..023e8005e0337f --- /dev/null +++ b/src/core_plugins/metrics/public/components/split.js @@ -0,0 +1,73 @@ +import React, { Component, PropTypes } from 'react'; +import Select from 'react-select'; +import _ from 'lodash'; +import FieldSelect from './aggs/field_select'; +import MetricSelect from './aggs/metric_select'; +import calculateLabel from './lib/calculate_label'; +import createTextHandler from './lib/create_text_handler'; +import createSelectHandler from './lib/create_select_handler'; +import uuid from 'node-uuid'; + +import SplitByTerms from './splits/terms'; +import SplitByFilter from './splits/filter'; +import SplitByFilters from './splits/filters'; +import SplitByEverything from './splits/everything'; + +class Split extends Component { + + componentWillReceiveProps(nextProps) { + const { model } = nextProps; + if (model.split_mode === 'filters' && !model.split_filters) { + this.props.onChange({ + split_filters: [ + { color: model.color, id: uuid.v1() } + ] + }); + } + } + + render() { + const { model, panel } = this.props; + const indexPattern = model.override_index_pattern && + model.series_index_pattern || + panel.index_pattern; + if (model.split_mode === 'filter') { + return ( + + ); + } + if (model.split_mode === 'filters') { + return ( + + ); + } + if (model.split_mode === 'terms') { + return ( + + ); + } + return ( + + ); + } + +} + +Split.propTypes = { + fields: PropTypes.object, + model: PropTypes.object, + onChange: PropTypes.func, + panel: PropTypes.object +}; + +export default Split; diff --git a/src/core_plugins/metrics/public/components/splits/everything.js b/src/core_plugins/metrics/public/components/splits/everything.js new file mode 100644 index 00000000000000..ae4ba3aa7be1be --- /dev/null +++ b/src/core_plugins/metrics/public/components/splits/everything.js @@ -0,0 +1,28 @@ +import createTextHandler from '../lib/create_text_handler'; +import createSelectHandler from '../lib/create_select_handler'; +import GroupBySelect from './group_by_select'; +import React, { Component, PropTypes } from 'react'; + +function SplitByEverything(props) { + const { onChange, model } = props; + const handleSelectChange = createSelectHandler(onChange); + return ( +
+
Group By
+
+ +
+
+ ); + +} + +SplitByEverything.propTypes = { + model: PropTypes.object, + onChange: PropTypes.func +}; + +export default SplitByEverything; + diff --git a/src/core_plugins/metrics/public/components/splits/filter.js b/src/core_plugins/metrics/public/components/splits/filter.js new file mode 100644 index 00000000000000..b39950f17e46aa --- /dev/null +++ b/src/core_plugins/metrics/public/components/splits/filter.js @@ -0,0 +1,38 @@ +import createTextHandler from '../lib/create_text_handler'; +import createSelectHandler from '../lib/create_select_handler'; +import GroupBySelect from './group_by_select'; +import React, { Component, PropTypes } from 'react'; + +class SplitByFilter extends Component { + + render() { + const { onChange } = this.props; + const defaults = { filter: '' }; + const model = { ...defaults, ...this.props.model }; + const handleTextChange = createTextHandler(onChange); + const handleSelectChange = createSelectHandler(onChange); + return ( +
+
Group By
+
+ +
+
Query String
+ +
+ ); + } + +} + +SplitByFilter.propTypes = { + model: PropTypes.object, + onChange: PropTypes.func +}; + +export default SplitByFilter; diff --git a/src/core_plugins/metrics/public/components/splits/filter_items.js b/src/core_plugins/metrics/public/components/splits/filter_items.js new file mode 100644 index 00000000000000..0740181c8e7a32 --- /dev/null +++ b/src/core_plugins/metrics/public/components/splits/filter_items.js @@ -0,0 +1,89 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import collectionActions from '../lib/collection_actions'; +import AddDeleteButtons from '../add_delete_buttons'; +import ColorPicker from '../color_picker'; +import uuid from 'node-uuid'; +class FilterItems extends Component { + + constructor(props) { + super(props); + this.renderRow = this.renderRow.bind(this); + } + + handleChange(item, name) { + return (e) => { + const handleChange = collectionActions.handleChange.bind(null, this.props); + handleChange(_.assign({}, item, { + [name]: _.get(e, 'value', _.get(e, 'target.value')) + })); + }; + } + + renderRow(row, i, items) { + const defaults = { filter: '', label: '' }; + const model = { ...defaults, ...row }; + const handleChange = (part) => { + const fn = collectionActions.handleChange.bind(null, this.props); + fn(_.assign({}, model, part)); + }; + const newFilter = () => ({ color: this.props.model.color, id: uuid.v1() }); + const handleAdd = collectionActions.handleAdd + .bind(null, this.props, newFilter); + const handleDelete = collectionActions.handleDelete + .bind(null, this.props, model); + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); + } + + render() { + const { model, name } = this.props; + if (!model[name]) return (
); + const rows = model[name].map(this.renderRow); + return ( +
+ { rows } +
+ ); + } + +} + +FilterItems.propTypes = { + name: PropTypes.string, + model: PropTypes.object, + onChange: PropTypes.func +}; + +export default FilterItems; diff --git a/src/core_plugins/metrics/public/components/splits/filters.js b/src/core_plugins/metrics/public/components/splits/filters.js new file mode 100644 index 00000000000000..c25f907220471f --- /dev/null +++ b/src/core_plugins/metrics/public/components/splits/filters.js @@ -0,0 +1,35 @@ +import createSelectHandler from '../lib/create_select_handler'; +import GroupBySelect from './group_by_select'; +import FilterItems from './filter_items'; +import React, { Component, PropTypes } from 'react'; +function SplitByFilters(props) { + const { onChange, model } = props; + const handleSelectChange = createSelectHandler(onChange); + return( +
+
+
Group By
+
+ +
+
+
+
+ +
+
+
+ ); +} + +SplitByFilters.propTypes = { + model: PropTypes.object, + onChange: PropTypes.func +}; + +export default SplitByFilters; diff --git a/src/core_plugins/metrics/public/components/splits/group_by_select.js b/src/core_plugins/metrics/public/components/splits/group_by_select.js new file mode 100644 index 00000000000000..653698885c0ea6 --- /dev/null +++ b/src/core_plugins/metrics/public/components/splits/group_by_select.js @@ -0,0 +1,25 @@ +import React, { PropTypes } from 'react'; +import Select from 'react-select'; +function GroupBySelect(props) { + const modeOptions = [ + { label: 'Everything', value: 'everything' }, + { label: 'Filter', value: 'filter' }, + { label: 'Filters', value: 'filters' }, + { label: 'Terms', value: 'terms' } + ]; + return ( + +
Order By
+
+ +
+
+ ); + } + +} + +SplitByTerms.propTypes = { + model: PropTypes.object, + onChange: PropTypes.func, + indexPattern: PropTypes.string, + fields: PropTypes.object +}; + +export default SplitByTerms; diff --git a/src/core_plugins/metrics/public/components/tooltip.js b/src/core_plugins/metrics/public/components/tooltip.js new file mode 100644 index 00000000000000..09c298c6aa924c --- /dev/null +++ b/src/core_plugins/metrics/public/components/tooltip.js @@ -0,0 +1,26 @@ +import React, { Component, PropTypes } from 'react'; +import { Tooltip } from 'pui-react-tooltip'; +import { OverlayTrigger } from 'pui-react-overlay-trigger'; + +function TooltipComponent(props) { + const tooltip = ( + { props.text } + ); + return ( + + { props.children} + + ); +} + +TooltipComponent.defaultProps = { + placement: 'top', + text: 'Tip!' +}; + +TooltipComponent.propTypes = { + placement: PropTypes.string, + text: PropTypes.node +}; + +export default TooltipComponent; diff --git a/src/core_plugins/metrics/public/components/vis_editor.js b/src/core_plugins/metrics/public/components/vis_editor.js new file mode 100644 index 00000000000000..8b4ccd97b4fe34 --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_editor.js @@ -0,0 +1,67 @@ +import React, { Component, PropTypes } from 'react'; +import VisEditorVisualization from './vis_editor_visualization'; +import Visualization from './visualization'; +import VisPicker from './vis_picker'; +import PanelConfig from './panel_config'; + +class VisEditor extends Component { + + constructor(props) { + super(props); + this.state = { model: props.model }; + } + + render() { + const handleChange = (part) => { + const nextModel = { ...this.state.model, ...part }; + this.setState({ model: nextModel }); + if (this.props.onChange) { + this.props.onChange(nextModel); + } + }; + + if (this.props.embedded) { + return ( + + ); + } + + + const { model } = this.state; + + if (model) { + return ( +
+ + + +
+ ); + } + return null; + } + +} + +VisEditor.propTypes = { + fields: PropTypes.object, + model: PropTypes.object, + onBrush: PropTypes.func, + onChange: PropTypes.func, + visData: PropTypes.object +}; + +export default VisEditor; diff --git a/src/core_plugins/metrics/public/components/vis_editor_visualization.js b/src/core_plugins/metrics/public/components/vis_editor_visualization.js new file mode 100644 index 00000000000000..29168b1c42d717 --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_editor_visualization.js @@ -0,0 +1,78 @@ +import React, { Component, PropTypes } from 'react'; +import Visualization from './visualization'; + +class VisEditorVisualization extends Component { + + constructor(props) { + super(props); + this.state = { + height: 250, + dragging: false + }; + + this.handleMouseUp = this.handleMouseUp.bind(this); + this.handleMouseDown = this.handleMouseDown.bind(this); + } + + handleMouseDown(e) { + this.setState({ dragging: true }); + } + + handleMouseUp(e) { + this.setState({ dragging: false }); + } + + componentWillMount() { + this.handleMouseMove = (event) => { + if (this.state.dragging) { + const height = this.state.height + event.movementY; + if (height > 250) { + this.setState({ height }); + } + } + }; + window.addEventListener('mousemove', this.handleMouseMove); + window.addEventListener('mouseup', this.handleMouseUp); + } + + componentWillUnmount() { + window.removeEventListener('mousemove', this.handleMouseMove); + window.removeEventListener('mouseup', this.handleMouseUp); + } + + render() { + const style = { height: this.state.height }; + if (this.state.dragging) { + style.userSelect = 'none'; + } + const visBackgroundColor = '#FFF'; + return ( +
+
+ +
+
+ +
+
+ ); + } +} + +VisEditorVisualization.propTypes = { + model: PropTypes.object, + onBrush: PropTypes.func, + onChange: PropTypes.func, + visData: PropTypes.object +}; + +export default VisEditorVisualization; diff --git a/src/core_plugins/metrics/public/components/vis_picker.js b/src/core_plugins/metrics/public/components/vis_picker.js new file mode 100644 index 00000000000000..70672b6e4c3836 --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_picker.js @@ -0,0 +1,68 @@ +import React, { Component, PropTypes } from 'react'; + +function VisPickerItem(props) { + const { label, icon, type } = props; + let itemClassName = 'vis_editor__vis_picker-item'; + let iconClassName = 'vis_editor__vis_picker-icon'; + let labelClassName = 'vis_editor__vis_picker-label'; + if (props.selected) { + itemClassName += ' selected'; + iconClassName += ' selected'; + labelClassName += ' selected'; + } + return ( +
props.onClick(type)}> +
+ +
+
+ { label } +
+
+ ); +} + +VisPickerItem.propTypes = { + icon: PropTypes.string, + label: PropTypes.string, + onClick: PropTypes.func, + type: PropTypes.string, + selected: PropTypes.bool +}; + +function VisPicker(props) { + const handleChange = (type) => { + props.onChange({ type }); + }; + + const { model } = props; + const icons = [ + { type: 'timeseries', icon: 'fa-line-chart', label: 'Time Series' }, + { type: 'metric', icon: 'fa-superscript', label: 'Metric' }, + { type: 'top_n', icon: 'fa-bar-chart fa-rotate-90', label: 'Top N' }, + { type: 'gauge', icon: 'fa-circle-o-notch', label: 'Gauge' }, + { type: 'markdown', icon: 'fa-paragraph', label: 'Markdown' } + ].map((item, i, items) => { + return ( + + ); + }); + + return ( +
+ { icons } +
+ ); + +} + +VisPicker.propTypes = { + model: PropTypes.object, + onChange: PropTypes.func +}; + +export default VisPicker; diff --git a/src/core_plugins/metrics/public/components/vis_types/gauge/series.js b/src/core_plugins/metrics/public/components/vis_types/gauge/series.js new file mode 100644 index 00000000000000..35eaecba461547 --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_types/gauge/series.js @@ -0,0 +1,167 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import ColorPicker from '../../color_picker'; +import AddDeleteButtons from '../../add_delete_buttons'; +import SeriesConfig from '../../series_config'; +import Sortable from 'react-anything-sortable'; +import Split from '../../split'; +import Tooltip from '../../tooltip'; +import createAggRowRender from '../../lib/create_agg_row_render'; +import createTextHandler from '../../lib/create_text_handler'; + +function GaugeSeries(props) { + const { + panel, + fields, + onAdd, + onChange, + onDelete, + disableDelete, + disableAdd, + selectedTab, + visible + } = props; + + const defaults = { label: '' }; + const model = { ...defaults, ...props.model }; + + const handleChange = createTextHandler(onChange); + const aggs = model.metrics.map(createAggRowRender(props)); + + let caretClassName = 'fa fa-caret-down'; + if (!visible) caretClassName = 'fa fa-caret-right'; + + let body = null; + if (visible) { + let metricsClassName = 'kbnTabs__tab'; + let optionsClassname = 'kbnTabs__tab'; + if (selectedTab === 'metrics') metricsClassName += '-active'; + if (selectedTab === 'options') optionsClassname += '-active'; + let seriesBody; + if (selectedTab === 'metrics') { + const handleSort = (data) => { + const metrics = data.map(id => model.metrics.find(m => m.id === id)); + props.onChange({ metrics }); + }; + seriesBody = ( +
+ + { aggs } + +
+
+ +
+
+
+ ); + } else { + seriesBody = ( + + ); + } + body = ( +
+
+
props.switchTab('metrics')}>Metrics
+
props.switchTab('options')}>Options
+
+ {seriesBody} +
+ ); + } + + let colorPicker; + if (props.colorPicker) { + colorPicker = ( + + ); + } + + let dragHandle; + if (!props.disableDelete) { + dragHandle = ( + +
+ +
+
+ ); + } + + return ( +
+
+
+
+ { colorPicker } +
+ +
+ { dragHandle } + +
+
+ { body } +
+ ); + +} + +GaugeSeries.propTypes = { + className: PropTypes.string, + colorPicker: PropTypes.bool, + disableAdd: PropTypes.bool, + disableDelete: PropTypes.bool, + fields: PropTypes.object, + name: PropTypes.string, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onClone: PropTypes.func, + onDelete: PropTypes.func, + onMouseDown: PropTypes.func, + onSortableItemMount: PropTypes.func, + onSortableItemReadyToMove: PropTypes.func, + onTouchStart: PropTypes.func, + model: PropTypes.object, + panel: PropTypes.object, + selectedTab: PropTypes.string, + sortData: PropTypes.string, + style: PropTypes.object, + switchTab: PropTypes.func, + toggleVisible: PropTypes.func, + visible: PropTypes.bool +}; + +export default GaugeSeries; diff --git a/src/core_plugins/metrics/public/components/vis_types/gauge/vis.js b/src/core_plugins/metrics/public/components/vis_types/gauge/vis.js new file mode 100644 index 00000000000000..c4d85b6d1522fe --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_types/gauge/vis.js @@ -0,0 +1,78 @@ +import React, { PropTypes } from 'react'; +import tickFormatter from '../../lib/tick_formatter'; +import _ from 'lodash'; +import { Gauge, getLastValue } from 'plugins/metrics/visualizations'; +import color from 'color'; + +function getColors(props) { + const { model, visData } = props; + const series = _.get(visData, `${model.id}.series`, []); + let text; + let gauge; + if (model.gauge_color_rules) { + model.gauge_color_rules.forEach((rule) => { + if (rule.opperator && rule.value != null) { + const value = series[0] && getLastValue(series[0].data) || 0; + if (_[rule.opperator](value, rule.value)) { + gauge = rule.gauge; + text = rule.text; + } + } + }); + } + return { text, gauge }; +} + +function GaugeVisualization(props) { + const { backgroundColor, model, visData } = props; + const colors = getColors(props); + const series = _.get(visData, `${model.id}.series`, []) + .map((row, i) => { + const seriesDef = model.series.find(s => _.includes(row.id, s.id)); + const newProps = {}; + if (seriesDef) { + newProps.formatter = tickFormatter(seriesDef.formatter, seriesDef.value_template); + } + if (i === 0 && colors.gauge) newProps.color = colors.gauge; + return _.assign({}, row, newProps); + }); + const params = { + metric: series[0], + type: model.gauge_style || 'half', + reversed: props.reversed + }; + + if (colors.text) { + params.valueColor = colors.text; + } + + if (model.gauge_width) params.gaugeLine = model.gauge_width; + if (model.gauge_inner_color) params.innerColor = model.gauge_inner_color; + if (model.gauge_inner_width) params.innerLine = model.gauge_inner_width; + if (model.gauge_max != null) params.max = model.gauge_max; + + const panelBackgroundColor = model.background_color || backgroundColor; + + if (panelBackgroundColor && panelBackgroundColor !== 'inherit') { + params.reversed = color(panelBackgroundColor).luminosity() < 0.45; + } + const style = { backgroundColor: panelBackgroundColor }; + + return ( +
+ +
+ ); +} + +GaugeVisualization.propTypes = { + backgroundColor: PropTypes.string, + className: PropTypes.string, + model: PropTypes.object, + onBrush: PropTypes.func, + onChange: PropTypes.func, + reversed: PropTypes.bool, + visData: PropTypes.object +}; + +export default GaugeVisualization; diff --git a/src/core_plugins/metrics/public/components/vis_types/markdown/series.js b/src/core_plugins/metrics/public/components/vis_types/markdown/series.js new file mode 100644 index 00000000000000..ffd80f5fd54af3 --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_types/markdown/series.js @@ -0,0 +1,149 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import ColorPicker from '../../color_picker'; +import AddDeleteButtons from '../../add_delete_buttons'; +import SeriesConfig from '../../series_config'; +import Sortable from 'react-anything-sortable'; +import Tooltip from '../../tooltip'; +import Split from '../../split'; +import calculateLabel from '../../lib/calculate_label'; +import createAggRowRender from '../../lib/create_agg_row_render'; +import createTextHandler from '../../lib/create_text_handler'; + +function MarkdownSeries(props) { + const { + panel, + fields, + onAdd, + onChange, + onDelete, + disableDelete, + disableAdd, + selectedTab, + visible + } = props; + + const defaults = { label: '', var_name: '' }; + const model = { ...defaults, ...props.model }; + + const handleChange = createTextHandler(onChange); + const aggs = model.metrics.map(createAggRowRender(props)); + + let caretClassName = 'fa fa-caret-down'; + if (!visible) caretClassName = 'fa fa-caret-right'; + + let body = null; + if (visible) { + let metricsClassName = 'kbnTabs__tab'; + let optionsClassname = 'kbnTabs__tab'; + if (selectedTab === 'metrics') metricsClassName += '-active'; + if (selectedTab === 'options') optionsClassname += '-active'; + let seriesBody; + if (selectedTab === 'metrics') { + const handleSort = (data) => { + const metrics = data.map(id => model.metrics.find(m => m.id === id)); + props.onChange({ metrics }); + }; + seriesBody = ( +
+ + { aggs } + +
+
+ +
+
+
+ ); + } else { + seriesBody = ( + + ); + } + body = ( +
+
+
props.switchTab('metrics')}>Metrics
+
props.switchTab('options')}>Options
+
+ {seriesBody} +
+ ); + } + + return ( +
+
+
+
+
+ + +
+ +
+
+ { body } +
+ ); + +} + +MarkdownSeries.propTypes = { + className: PropTypes.string, + colorPicker: PropTypes.bool, + disableAdd: PropTypes.bool, + disableDelete: PropTypes.bool, + fields: PropTypes.object, + name: PropTypes.string, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onClone: PropTypes.func, + onDelete: PropTypes.func, + onMouseDown: PropTypes.func, + onSortableItemMount: PropTypes.func, + onSortableItemReadyToMove: PropTypes.func, + onTouchStart: PropTypes.func, + model: PropTypes.object, + panel: PropTypes.object, + selectedTab: PropTypes.string, + sortData: PropTypes.string, + style: PropTypes.object, + switchTab: PropTypes.func, + toggleVisible: PropTypes.func, + visible: PropTypes.bool +}; + +export default MarkdownSeries; diff --git a/src/core_plugins/metrics/public/components/vis_types/markdown/vis.js b/src/core_plugins/metrics/public/components/vis_types/markdown/vis.js new file mode 100644 index 00000000000000..bd027a5e2a9d59 --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_types/markdown/vis.js @@ -0,0 +1,61 @@ +import React, { PropTypes } from 'react'; +import tickFormatter from '../../lib/tick_formatter'; +import _ from 'lodash'; +import { getLastValue } from 'plugins/metrics/visualizations'; +import color from 'color'; +import Markdown from 'react-markdown'; +import replaceVars from '../../lib/replace_vars'; +import convertSeriesToVars from '../../lib/convert_series_to_vars'; + +function MarkdownVisualization(props) { + const { backgroundColor, model, visData } = props; + const series = _.get(visData, `${model.id}.series`, []); + const variables = convertSeriesToVars(series, model); + const style = { }; + let reversed = props.reversed; + const panelBackgroundColor = model.background_color || backgroundColor; + if (panelBackgroundColor) { + style.backgroundColor = panelBackgroundColor; + reversed = color(panelBackgroundColor).luminosity() < 0.45; + } + let markdown; + if (model.markdown) { + const markdownSource = replaceVars(model.markdown, {}, { + _all: variables, + ...variables + }); + let className = 'thorMarkdown'; + let contentClassName = `thorMarkdown__content ${model.markdown_vertical_align}`; + if (model.markdown_scrollbars) contentClassName += ' scrolling'; + if (reversed) className += ' reversed'; + markdown = ( +
+ +
+
+ +
+
+
+ ); + } + return ( +
+ {markdown} +
+ ); +} + +MarkdownVisualization.propTypes = { + backgroundColor: PropTypes.string, + className: PropTypes.string, + model: PropTypes.object, + onBrush: PropTypes.func, + onChange: PropTypes.func, + reversed: PropTypes.bool, + visData: PropTypes.object +}; + +export default MarkdownVisualization; diff --git a/src/core_plugins/metrics/public/components/vis_types/metric/series.js b/src/core_plugins/metrics/public/components/vis_types/metric/series.js new file mode 100644 index 00000000000000..9da8aab8fc0f08 --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_types/metric/series.js @@ -0,0 +1,167 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import ColorPicker from '../../color_picker'; +import AddDeleteButtons from '../../add_delete_buttons'; +import SeriesConfig from '../../series_config'; +import Sortable from 'react-anything-sortable'; +import Split from '../../split'; +import Tooltip from '../../tooltip'; +import createAggRowRender from '../../lib/create_agg_row_render'; +import createTextHandler from '../../lib/create_text_handler'; + +function MetricSeries(props) { + const { + panel, + fields, + onAdd, + onChange, + onDelete, + disableDelete, + disableAdd, + selectedTab, + visible + } = props; + + const defaults = { label: '' }; + const model = { ...defaults, ...props.model }; + + const handleChange = createTextHandler(onChange); + const aggs = model.metrics.map(createAggRowRender(props)); + + let caretClassName = 'fa fa-caret-down'; + if (!visible) caretClassName = 'fa fa-caret-right'; + + let body = null; + if (visible) { + let metricsClassName = 'kbnTabs__tab'; + let optionsClassname = 'kbnTabs__tab'; + if (selectedTab === 'metrics') metricsClassName += '-active'; + if (selectedTab === 'options') optionsClassname += '-active'; + let seriesBody; + if (selectedTab === 'metrics') { + const handleSort = (data) => { + const metrics = data.map(id => model.metrics.find(m => m.id === id)); + props.onChange({ metrics }); + }; + seriesBody = ( +
+ + { aggs } + +
+
+ +
+
+
+ ); + } else { + seriesBody = ( + + ); + } + body = ( +
+
+
props.switchTab('metrics')}>Metrics
+
props.switchTab('options')}>Options
+
+ {seriesBody} +
+ ); + } + + let colorPicker; + if (props.colorPicker) { + colorPicker = ( + + ); + } + + let dragHandle; + if (!props.disableDelete) { + dragHandle = ( + +
+ +
+
+ ); + } + + return ( +
+
+
+
+ { colorPicker } +
+ +
+ { dragHandle } + +
+
+ { body } +
+ ); + +} + +MetricSeries.propTypes = { + className: PropTypes.string, + colorPicker: PropTypes.bool, + disableAdd: PropTypes.bool, + disableDelete: PropTypes.bool, + fields: PropTypes.object, + name: PropTypes.string, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onClone: PropTypes.func, + onDelete: PropTypes.func, + onMouseDown: PropTypes.func, + onSortableItemMount: PropTypes.func, + onSortableItemReadyToMove: PropTypes.func, + onTouchStart: PropTypes.func, + model: PropTypes.object, + panel: PropTypes.object, + selectedTab: PropTypes.string, + sortData: PropTypes.string, + style: PropTypes.object, + switchTab: PropTypes.func, + toggleVisible: PropTypes.func, + visible: PropTypes.bool +}; + +export default MetricSeries; diff --git a/src/core_plugins/metrics/public/components/vis_types/metric/vis.js b/src/core_plugins/metrics/public/components/vis_types/metric/vis.js new file mode 100644 index 00000000000000..f83b91b0b05b6a --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_types/metric/vis.js @@ -0,0 +1,73 @@ +import React, { PropTypes } from 'react'; +import tickFormatter from '../../lib/tick_formatter'; +import _ from 'lodash'; +import { Metric, getLastValue } from 'plugins/metrics/visualizations'; +import { findDOMNode } from 'react-dom'; +import color from 'color'; + + +function getColors(props) { + const { model, visData } = props; + const series = _.get(visData, `${model.id}.series`, []); + let color; + let background; + if (model.background_color_rules) { + model.background_color_rules.forEach((rule) => { + if (rule.opperator && rule.value != null) { + const value = series[0] && getLastValue(series[0].data) || 0; + if (_[rule.opperator](value, rule.value)) { + background = rule.background_color; + color = rule.color; + } + } + }); + } + return { color, background }; +} + +function MetricVisualization(props) { + const { backgroundColor, model, visData } = props; + const colors = getColors(props); + const series = _.get(visData, `${model.id}.series`, []) + .map((row, i) => { + const seriesDef = model.series.find(s => _.includes(row.id, s.id)); + const newProps = {}; + if (seriesDef) { + newProps.formatter = tickFormatter(seriesDef.formatter, seriesDef.value_template); + } + if (i === 0 && colors.color) newProps.color = colors.color; + return _.assign({}, _.pick(row, ['label', 'data']), newProps); + }); + const params = { + metric: series[0], + reversed: props.reversed + }; + if (series[1]) { + params.secondary = series[1]; + } + + const panelBackgroundColor = colors.background || backgroundColor; + + if (panelBackgroundColor && panelBackgroundColor !== 'inherit') { + params.reversed = color(panelBackgroundColor).luminosity() < 0.45; + } + const style = { backgroundColor: panelBackgroundColor }; + return ( +
+ +
+ ); + +} + +MetricVisualization.propTypes = { + backgroundColor: PropTypes.string, + className: PropTypes.string, + model: PropTypes.object, + onBrush: PropTypes.func, + onChange: PropTypes.func, + reversed: PropTypes.bool, + visData: PropTypes.object +}; + +export default MetricVisualization; diff --git a/src/core_plugins/metrics/public/components/vis_types/timeseries/config.js b/src/core_plugins/metrics/public/components/vis_types/timeseries/config.js new file mode 100644 index 00000000000000..35b6a07df9c6a8 --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_types/timeseries/config.js @@ -0,0 +1,221 @@ +import React, { Component, PropTypes } from 'react'; +import Select from 'react-select'; +import DataFormatPicker from '../../data_format_picker'; +import createSelectHandler from '../../lib/create_select_handler'; +import YesNo from '../../yes_no'; +import createTextHandler from '../../lib/create_text_handler'; +import IndexPattern from '../../index_pattern'; + +function TimeseriesConfig(props) { + const handleSelectChange = createSelectHandler(props.onChange); + const handleTextChange = createTextHandler(props.onChange); + + const defaults = { + fill: '', + line_width: '', + point_size: '', + value_template: '{{value}}', + offset_time: '', + split_color_mode: 'gradient', + axis_min: '', + axis_max: '', + stacked: 'none', + steps: 0 + }; + const model = { ...defaults, ...props.model }; + + const stackedOptions = [ + { label: 'None', value: 'none' }, + { label: 'Stacked', value: 'stacked' }, + { label: 'Percent', value: 'percent' } + ]; + + const positionOptions = [ + { label: 'Right', value: 'right' }, + { label: 'Left', value: 'left' } + ]; + + const chartTypeOptions = [ + { label: 'Bar', value: 'bar' }, + { label: 'Line', value: 'line' } + ]; + + const splitColorOptions = [ + { label: 'Gradient', value: 'gradient' }, + { label: 'Rainbow', value: 'rainbow' } + ]; + + let type; + if (model.chart_type === 'line') { + type = ( +
+
Chart Type
+
+ +
+
Fill (0 to 1)
+ +
Line Width
+ +
Point Size
+ +
Steps
+ +
+ ); + } + if (model.chart_type === 'bar') { + type = ( +
+
Chart Type
+
+ +
+
Fill (0 to 1)
+ +
Line Width
+ +
+ ); + } + + const disableSeperateYaxis = model.seperate_axis ? false : true; + + return ( +
+
+
+ +
Template (eg.{'{{value}}/s'})
+ +
+ { type } +
+
Offset series time by (1m, 1h, 1w, 1d)
+ +
Hide in Legend
+ +
Split Color Theme
+
+ +
Axis Max
+ +
Axis Position
+
+ +
+ { dragHandle } + +
+
+ { body } +
+ ); + +} + +TimeseriesSeries.propTypes = { + className: PropTypes.string, + colorPicker: PropTypes.bool, + disableAdd: PropTypes.bool, + disableDelete: PropTypes.bool, + fields: PropTypes.object, + name: PropTypes.string, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onClone: PropTypes.func, + onDelete: PropTypes.func, + onMouseDown: PropTypes.func, + onSortableItemMount: PropTypes.func, + onSortableItemReadyToMove: PropTypes.func, + onTouchStart: PropTypes.func, + model: PropTypes.object, + panel: PropTypes.object, + selectedTab: PropTypes.string, + sortData: PropTypes.string, + style: PropTypes.object, + switchTab: PropTypes.func, + toggleVisible: PropTypes.func, + visible: PropTypes.bool +}; + +export default TimeseriesSeries; diff --git a/src/core_plugins/metrics/public/components/vis_types/timeseries/vis.js b/src/core_plugins/metrics/public/components/vis_types/timeseries/vis.js new file mode 100644 index 00000000000000..be8a8c6a7a3f6f --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_types/timeseries/vis.js @@ -0,0 +1,147 @@ +import React, { PropTypes } from 'react'; +import tickFormatter from '../../lib/tick_formatter'; +import _ from 'lodash'; +import { Timeseries } from 'plugins/metrics/visualizations'; +import color from 'color'; +import replaceVars from '../../lib/replace_vars'; + +function hasSeperateAxis(row) { + return row.seperate_axis; +} + +function TimeseriesVisualization(props) { + const { backgroundColor, model, visData } = props; + const series = _.get(visData, `${model.id}.series`, []); + let annotations; + if (model.annotations && _.isArray(model.annotations)) { + annotations = model.annotations.map(annotation => { + const data = _.get(visData, `${model.id}.annotations.${annotation.id}`, []) + .map(item => [item.key, item.docs]); + return { + id: annotation.id, + color: annotation.color, + icon: annotation.icon, + series: data.map(s => { + return [s[0], s[1].map(doc => { + return replaceVars(annotation.template, null, doc); + })]; + }) + }; + }); + } + const seriesModel = model.series.map(s => _.cloneDeep(s)); + const firstSeries = seriesModel.find(s => s.formatter && !s.seperate_axis); + const formatter = tickFormatter(_.get(firstSeries, 'formatter'), _.get(firstSeries, 'value_template')); + + const mainAxis = { + position: model.axis_position, + tickFormatter: formatter, + axis_formatter: _.get(firstSeries, 'formatter', 'number'), + }; + + if (model.axis_min) mainAxis.min = model.axis_min; + if (model.axis_max) mainAxis.max = model.axis_max; + + const yaxes = [mainAxis]; + + + seriesModel.forEach(s => { + series + .filter(r => _.startsWith(r.id, s.id)) + .forEach(r => r.tickFormatter = tickFormatter(s.formatter, s.value_template)); + + if (s.hide_in_legend) { + series + .filter(r => _.startsWith(r.id, s.id)) + .forEach(r => delete r.label); + } + if (s.stacked === 'percent') { + s.seperate_axis = true; + s.axis_formatter = 'percent'; + s.axis_min = 0; + s.axis_max = 1; + s.axis_position = model.axis_position; + const seriesData = series.filter(r => _.startsWith(r.id, s.id)); + const first = seriesData[0]; + if (first) { + first.data.forEach((row, index) => { + const rowSum = seriesData.reduce((acc, item) => { + return item.data[index][1] + acc; + }, 0); + seriesData.forEach(item => { + item.data[index][1] = rowSum && item.data[index][1] / rowSum || 0; + }); + }); + } + } + }); + + + let axisCount = 1; + if (seriesModel.some(hasSeperateAxis)) { + seriesModel.forEach((row) => { + if (row.seperate_axis) { + axisCount++; + + const formatter = tickFormatter(row.formatter, row.value_template); + + const yaxis = { + alignTicksWithAxis: 1, + position: row.axis_position, + tickFormatter: formatter, + axis_formatter: row.axis_formatter + }; + + if (row.axis_min != null) yaxis.min = row.axis_min; + if (row.axis_max != null) yaxis.max = row.axis_max; + + yaxes.push(yaxis); + + // Assign axis and formatter to each series + series + .filter(r => _.startsWith(r.id, row.id)) + .forEach(r => { + r.yaxis = axisCount; + }); + } + }); + } + + const params = { + crosshair: true, + tickFormatter: formatter, + legendPosition: model.legend_position || 'right', + series, + annotations, + yaxes, + reversed: props.reversed, + legend: Boolean(model.show_legend), + onBrush: (ranges) => { + if (props.onBrush) props.onBrush(ranges); + } + }; + const style = { }; + const panelBackgroundColor = model.background_color || backgroundColor; + if (panelBackgroundColor) { + style.backgroundColor = panelBackgroundColor; + params.reversed = color(panelBackgroundColor || backgroundColor).luminosity() < 0.45; + } + return ( +
+ +
+ ); + +} + +TimeseriesVisualization.propTypes = { + backgroundColor: PropTypes.string, + className: PropTypes.string, + model: PropTypes.object, + onBrush: PropTypes.func, + onChange: PropTypes.func, + reversed: PropTypes.bool, + visData: PropTypes.object +}; + +export default TimeseriesVisualization; diff --git a/src/core_plugins/metrics/public/components/vis_types/top_n/series.js b/src/core_plugins/metrics/public/components/vis_types/top_n/series.js new file mode 100644 index 00000000000000..4e2390a6aa99c1 --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_types/top_n/series.js @@ -0,0 +1,122 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import ColorPicker from '../../color_picker'; +import AddDeleteButtons from '../../add_delete_buttons'; +import SeriesConfig from '../../series_config'; +import Sortable from 'react-anything-sortable'; +import Tooltip from '../../tooltip'; +import MetricSelect from '../../aggs/metric_select'; +import Split from '../../split'; +import { handleChange } from '../../lib/collection_actions'; +import createAggRowRender from '../../lib/create_agg_row_render'; + +function TopNSeries(props) { + const { + panel, + model, + fields, + onAdd, + onDelete, + disableDelete, + disableAdd, + selectedTab, + visible, + } = props; + + const aggs = model.metrics.map(createAggRowRender(props)); + + let caretClassName = 'fa fa-caret-down'; + if (!visible) caretClassName = 'fa fa-caret-right'; + + let body = null; + if (visible) { + let metricsClassName = 'kbnTabs__tab'; + let optionsClassname = 'kbnTabs__tab'; + if (selectedTab === 'metrics') metricsClassName += '-active'; + if (selectedTab === 'options') optionsClassname += '-active'; + let seriesBody; + if (selectedTab === 'metrics') { + const handleSort = (data) => { + const metrics = data.map(id => model.metrics.find(m => m.id === id)); + props.onChange({ metrics }); + }; + seriesBody = ( +
+ + { aggs } + +
+
+ +
+
+
+ ); + } else { + seriesBody = ( + + ); + } + body = ( +
+
+
props.switchTab('metrics')}>Metrics
+
props.switchTab('options')}>Options
+
+ {seriesBody} +
+ ); + } + + return ( +
+ { body } +
+ ); + +} + +TopNSeries.propTypes = { + className: PropTypes.string, + colorPicker: PropTypes.bool, + disableAdd: PropTypes.bool, + disableDelete: PropTypes.bool, + fields: PropTypes.object, + name: PropTypes.string, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onClone: PropTypes.func, + onDelete: PropTypes.func, + onMouseDown: PropTypes.func, + onSortableItemMount: PropTypes.func, + onSortableItemReadyToMove: PropTypes.func, + onTouchStart: PropTypes.func, + model: PropTypes.object, + panel: PropTypes.object, + selectedTab: PropTypes.string, + sortData: PropTypes.string, + style: PropTypes.object, + switchTab: PropTypes.func, + toggleVisible: PropTypes.func, + visible: PropTypes.bool +}; + +export default TopNSeries; diff --git a/src/core_plugins/metrics/public/components/vis_types/top_n/vis.js b/src/core_plugins/metrics/public/components/vis_types/top_n/vis.js new file mode 100644 index 00000000000000..79cf04387b42eb --- /dev/null +++ b/src/core_plugins/metrics/public/components/vis_types/top_n/vis.js @@ -0,0 +1,63 @@ +import tickFormatter from '../../lib/tick_formatter'; +import _ from 'lodash'; +import { TopN, getLastValue } from 'plugins/metrics/visualizations'; +import color from 'color'; + +import React, { PropTypes } from 'react'; +function TopNVisualization(props) { + const { backgroundColor, model, visData } = props; + + const series = _.get(visData, `${model.id}.series`, []) + .map(item => { + const id = _.first(item.id.split(/:/)); + const seriesConfig = model.series.find(s => s.id === id); + if (seriesConfig) { + const formatter = tickFormatter(seriesConfig.formatter, seriesConfig.value_template); + const value = getLastValue(item.data, item.data.length); + let color = item.color || seriesConfig.color; + if (model.bar_color_rules) { + model.bar_color_rules.forEach(rule => { + if (rule.opperator && rule.value != null && rule.bar_color) { + if (_[rule.opperator](value, rule.value)) { + color = rule.bar_color; + } + } + }); + } + return _.assign({}, item, { + color, + tickFormatter: formatter + }); + } + return item; + }); + + const params = { + series: series, + reversed: props.reversed + }; + const panelBackgroundColor = model.background_color || backgroundColor; + + if (panelBackgroundColor && panelBackgroundColor !== 'inherit') { + params.reversed = color(panelBackgroundColor).luminosity() < 0.45; + } + const style = { backgroundColor: panelBackgroundColor }; + return ( +
+ +
+ ); + +} + +TopNVisualization.propTypes = { + backgroundColor: PropTypes.string, + className: PropTypes.string, + model: PropTypes.object, + onBrush: PropTypes.func, + onChange: PropTypes.func, + reversed: PropTypes.bool, + visData: PropTypes.object +}; + +export default TopNVisualization; diff --git a/src/core_plugins/metrics/public/components/visualization.js b/src/core_plugins/metrics/public/components/visualization.js new file mode 100644 index 00000000000000..bf29f524ec6b68 --- /dev/null +++ b/src/core_plugins/metrics/public/components/visualization.js @@ -0,0 +1,58 @@ +import React, { PropTypes } from 'react'; +import _ from 'lodash'; + +import timeseries from './vis_types/timeseries/vis'; +import metric from './vis_types/metric/vis'; +import topN from './vis_types/top_n/vis'; +import gauge from './vis_types/gauge/vis'; +import markdown from './vis_types/markdown/vis'; +import Error from './error'; + +const types = { + timeseries, + metric, + top_n: topN, + gauge, + markdown +}; + +function Visualization(props) { + const { visData, model } = props; + // Show the error panel + const error = _.get(visData, `${model.id}.error`); + if (error) { + return ( +
+ +
+ ); + } + const component = types[model.type]; + if (component) { + return React.createElement(component, { + reversed: props.reversed, + backgroundColor: props.backgroundColor, + model: props.model, + onBrush: props.onBrush, + onChange: props.onChange, + visData: props.visData + }); + } + return (
); +} + +Visualization.defaultProps = { + className: 'thor__visualization' +}; + +Visualization.propTypes = { + backgroundColor: PropTypes.string, + className: PropTypes.string, + model: PropTypes.object, + onBrush: PropTypes.func, + onChange: PropTypes.func, + reversed: PropTypes.bool, + visData: PropTypes.object +}; + +export default Visualization; diff --git a/src/core_plugins/metrics/public/components/yes_no.js b/src/core_plugins/metrics/public/components/yes_no.js new file mode 100644 index 00000000000000..8c654b76a0d858 --- /dev/null +++ b/src/core_plugins/metrics/public/components/yes_no.js @@ -0,0 +1,41 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; + +function YesNo(props) { + const { name, value } = props; + const handleChange = value => { + const { name } = props; + return (e) => { + const parts = { [name]: value }; + props.onChange(parts); + }; + }; + const inputName = name + _.uniqueId(); + return ( +
+ + +
+ ); +} + +YesNo.propTypes = { + name: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) +}; + +export default YesNo; diff --git a/src/core_plugins/metrics/public/directives/vis_editor.js b/src/core_plugins/metrics/public/directives/vis_editor.js new file mode 100644 index 00000000000000..aa9dffe508fad1 --- /dev/null +++ b/src/core_plugins/metrics/public/directives/vis_editor.js @@ -0,0 +1,27 @@ +import _ from 'lodash'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import modules from 'ui/modules'; +import VisEditor from '../components/vis_editor'; +import addScope from '../lib/add_scope'; +import angular from 'angular'; +import createBrushHandler from '../lib/create_brush_handler'; +const app = modules.get('apps/metrics/directives'); +app.directive('metricsVisEditor', (timefilter) => { + return { + restrict: 'E', + link: ($scope, $el, $attrs) => { + const addToState = ['embedded', 'fields', 'visData']; + const Component = addScope(VisEditor, $scope, addToState); + const handleBrush = createBrushHandler($scope, timefilter); + const handleChange = part => { + $scope.$evalAsync(() => angular.copy(part, $scope.model)); + }; + render(, $el[0]); + $scope.$on('$destroy', () => { + unmountComponentAtNode($el[0]); + }); + } + }; +}); + diff --git a/src/core_plugins/metrics/public/directives/visualization.js b/src/core_plugins/metrics/public/directives/visualization.js new file mode 100644 index 00000000000000..866b6ad67a488d --- /dev/null +++ b/src/core_plugins/metrics/public/directives/visualization.js @@ -0,0 +1,41 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import Visualization from '../components/visualization'; +import addScope from '../lib/add_scope'; +import modules from 'ui/modules'; +import createBrushHandler from '../lib/create_brush_handler'; +const app = modules.get('apps/metrics/directives'); +app.directive('metricsVisualization', (timefilter) => { + return { + restrict: 'E', + link: ($scope, $el, $attrs) => { + const addToState = ['model', 'visData', 'reversed']; + const Component = addScope(Visualization, $scope, addToState); + const handleBrush = createBrushHandler($scope, timefilter); + render(, $el[0]); + $scope.$on('$destroy', () => unmountComponentAtNode($el[0])); + + // For Metrics, Gauges and markdown visualizations we want to hide the + // panel title because it just doens't make sense to show it. + const panel = $($el[0]).parents('.panel'); + if (panel.length) { + const panelHeading = panel.find('.panel-heading'); + const panelTitle = panel.find('.panel-title'); + const matchingTypes = ['metric', 'gauge', 'markdown']; + if (panelHeading.length && panelTitle.length && _.contains(matchingTypes, $scope.model.type)) { + panel.css({ position: 'relative' }); + panelHeading.css({ + position: 'absolute', + top: 0, + right: 0, + zIndex: 100 + }); + panelTitle.css({ display: 'none' }); + } + } + } + }; +}); + diff --git a/src/core_plugins/metrics/public/kbn_vis_types/editor.html b/src/core_plugins/metrics/public/kbn_vis_types/editor.html new file mode 100644 index 00000000000000..9b7a3abf51298f --- /dev/null +++ b/src/core_plugins/metrics/public/kbn_vis_types/editor.html @@ -0,0 +1,6 @@ +
+ +
diff --git a/src/core_plugins/metrics/public/kbn_vis_types/editor_controller.js b/src/core_plugins/metrics/public/kbn_vis_types/editor_controller.js new file mode 100644 index 00000000000000..61dbbb40d9bc36 --- /dev/null +++ b/src/core_plugins/metrics/public/kbn_vis_types/editor_controller.js @@ -0,0 +1,110 @@ +import modules from 'ui/modules'; +import '../services/executor'; +import 'plugins/timelion/directives/refresh_hack'; +import $ from 'jquery'; +import createNewPanel from '../lib/create_new_panel'; +import '../directives/vis_editor'; +import _ from 'lodash'; +import angular from 'angular'; +const app = modules.get('kibana/metrics_vis', ['kibana']); +app.controller('MetricsEditorController', ( + $location, + $element, + $scope, + Private, + timefilter, + metricsExecutor +) => { + + $scope.embedded = $location.search().embed === 'true'; + const queryFilter = Private(require('ui/filter_bar/query_filter')); + const createFetch = Private(require('../lib/fetch')); + const fetch = () => { + const fn = createFetch($scope); + return fn().then((resp) => { + $element.trigger('renderComplete'); + return resp; + }); + }; + const fetchFields = Private(require('../lib/fetch_fields')); + + const debouncedFetch = _.debounce(() => { + fetch(); + }, 1000, { + leading: false, + trailing: true + }); + + const debouncedFetchFields = _.debounce(fetchFields($scope), 1000, { + leading: false, + trailing: true + }); + + // If the model doesn't exist we need to either intialize it with a copy from + // the $scope.vis._editableVis.params or create a new panel all together. + if (!$scope.model) { + if ($scope.vis._editableVis.params.type) { + $scope.model = _.assign({}, $scope.vis._editableVis.params); + } else { + $scope.model = createNewPanel(); + angular.copy($scope.model, $scope.vis._editableVis.params); + } + } + + $scope.$watchCollection('model', (newValue, oldValue) => { + angular.copy(newValue, $scope.vis._editableVis.params); + // When the content of the model changes we need to stage the changes to + // the Editable visualization. Normally this is done through clicking the + // play which triggers `stageEditableVis` in kibana/public/visualize/editor/editor.js + // but because we are auto running everything that doesn't work with our worflow + // plus it's covered up by the Thor editor UI. + const visAppScope = angular.element($('visualize-app')).scope(); + visAppScope.stageEditableVis(); + debouncedFetch(); + + const patternsToFetch = []; + // Fetch any missing index patterns + if (!$scope.fields[newValue.index_pattern]) { + patternsToFetch.push(newValue.index_pattern); + } + + newValue.series.forEach(series => { + if (series.override_index_pattern && + !$scope.fields[series.series_index_pattern]) { + patternsToFetch.push(series.series_index_pattern); + } + }); + + if (newValue.annotations) { + newValue.annotations.forEach(item => { + if (item.index_pattern && + !$scope.fields[item.index_pattern]) { + patternsToFetch.push(item.index_pattern); + } + }); + } + + if(patternsToFetch.length) { + debouncedFetchFields(_.unique(patternsToFetch)); + } + }); + + $scope.visData = {}; + $scope.fields = {}; + // All those need to be consolidated + $scope.$listen(queryFilter, 'fetch', fetch); + $scope.$on('fetch', fetch); + + fetchFields($scope)($scope.model.index_pattern); + + // Register fetch + metricsExecutor.register({ execute: fetch }); + + // Start the executor + metricsExecutor.start(); + + // Destory the executor + $scope.$on('$destroy', metricsExecutor.destroy); + +}); + diff --git a/src/core_plugins/metrics/public/kbn_vis_types/index.js b/src/core_plugins/metrics/public/kbn_vis_types/index.js new file mode 100644 index 00000000000000..8e6a1b8183c415 --- /dev/null +++ b/src/core_plugins/metrics/public/kbn_vis_types/index.js @@ -0,0 +1,31 @@ +import './vis_controller'; +import './editor_controller'; +import '../visualizations/less/main.less'; +import 'react-select/dist/react-select.css'; +import '../less/main.less'; + + // register the provider with the visTypes registry so that other know it exists +import visTypes from 'ui/registry/vis_types'; +visTypes.register(MetricsVisProvider); + +export default function MetricsVisProvider(Private) { + const TemplateVisType = Private(require('ui/template_vis_type')); + + // return the visType object, which kibana will use to display and configure new + // Vis object of this type. + return new TemplateVisType({ + name: 'metrics', + title: 'Time Series Visual Builder', + icon: 'fa-area-chart', + description: `Create a time series based visualization for metrics. Perfect + for creating visualizations for time series based metrics using the + powerful pipeline aggs Elasticsearch feature`, + template: require('./vis.html'), + fullEditor: true, + params: { + editor: require('./editor.html') + }, + requiresSearch: false, + implementsRenderComplete: true, + }); +} diff --git a/src/core_plugins/metrics/public/kbn_vis_types/vis.html b/src/core_plugins/metrics/public/kbn_vis_types/vis.html new file mode 100644 index 00000000000000..94eab1b5b547fc --- /dev/null +++ b/src/core_plugins/metrics/public/kbn_vis_types/vis.html @@ -0,0 +1,5 @@ +
+ +
diff --git a/src/core_plugins/metrics/public/kbn_vis_types/vis_controller.js b/src/core_plugins/metrics/public/kbn_vis_types/vis_controller.js new file mode 100644 index 00000000000000..d12e34fe7b5b57 --- /dev/null +++ b/src/core_plugins/metrics/public/kbn_vis_types/vis_controller.js @@ -0,0 +1,48 @@ +import modules from 'ui/modules'; +import 'plugins/timelion/directives/refresh_hack'; +import 'ui/state_management/app_state'; +import '../directives/visualization'; +const app = modules.get('kibana/metrics_vis'); + +app.controller('MetricsVisController', ( + $scope, + $element, + Private, + timefilter, + getAppState, + $location +) => { + + // If we are in the visualize editor context (and not embedded) we should not + // render the visualizations. This is handled by the editor itself. + const embedded = $location.search().embed === 'true'; + if (!embedded && $scope.vis._editableVis) { + return; + } + // We need to watch the app state for changes to the dark theme attribute. + $scope.state = getAppState(); + $scope.$watch('state.options.darkTheme', newValue => { + $scope.reversed = Boolean(newValue); + }); + + const queryFilter = Private(require('ui/filter_bar/query_filter')); + const createFetch = Private(require('../lib/fetch')); + const fetch = () => { + const fn = createFetch($scope); + return fn().then((resp) => { + $element.trigger('renderComplete'); + return resp; + }); + }; + + + $scope.model = $scope.vis.params; + $scope.$watch('vis.params', fetch); + + // All those need to be consolidated + $scope.$listen(timefilter, 'fetch', fetch); + $scope.$listen(queryFilter, 'fetch', fetch); + + $scope.$on('courier:searchRefresh', fetch); + $scope.$on('fetch', fetch); +}); diff --git a/src/core_plugins/metrics/public/less/color_picker.less b/src/core_plugins/metrics/public/less/color_picker.less new file mode 100644 index 00000000000000..ee715d9597a462 --- /dev/null +++ b/src/core_plugins/metrics/public/less/color_picker.less @@ -0,0 +1,89 @@ +.color_picker { + background-color: #fff; + border-radius: 2px; + box-shadow: 0 0 2px rgba(0,0,0,0.3), 0 4px 8px rgba(0,0,0,0.3); + box-sizing: initial; + width: 275px; + font-family: 'Menlo'; +} + +.color_picker__saturation { + width: 100%; + padding-bottom: 55%; + position: relative; + border-radius: 2px 2px 0 0; + overflow: hidden; +} + +.color_picker__body { + padding: 16px 16px 12px; +} + +.color_picker__controls { + display: flex; +} + +.color_picker__color { + width: 32px; +} + +.color_picker__color-disable_alpha { + width: 22px; +} + +.color_picker__swatch { + margin-top: 6px; + width: 16px; + height: 16px; + border-radius: 8px; + position: relative; + overflow: hidden; +} + +.color_picker__swatch-disable_alpha { + width: 10px; + height: 10px; + margin: 0px; +} + +.color_picker__active { + position: absolute; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; + border-radius: 8px; + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.1); + z-index: 2; +} + +.color_picker__toggles { + flex: 1 +} + +.color_picker__hue { + height: 10px; + position: relative; + margin-bottom: 8px; +} + +.color_picker__hue-disable_alpha { + margin-bottom: 0px; +} + +.color_picker__alpha { + height: 10px; + position: relative; +} + +.color_picker__alpha-disable_alpha { + display: none; +} + +.color_picker__swatches { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin-top: 10px; +} + diff --git a/src/core_plugins/metrics/public/less/color_rules.less b/src/core_plugins/metrics/public/less/color_rules.less new file mode 100644 index 00000000000000..d19eba6375bbbc --- /dev/null +++ b/src/core_plugins/metrics/public/less/color_rules.less @@ -0,0 +1,36 @@ +.color_rules { + flex: 1 0 auto; +} + +.color_rules__rule { + background-color: @grayLightest; + padding: 10px; + margin-bottom: 5px; + display: flex; + align-items: center; + flex: 1 0 auto; +} + +.color_rules__item { + flex: 1 0 auto; + margin-right: 10px; +} + +.color_rules__label { + margin: 0 10px; +} + +.color_rules__input { + padding: 6px 10px; + border-radius: 4px; + border: 1px solid @grayLight; + flex: 1 0 auto; + margin-right: 10px; +} + +.color_rules__secondary { + display: flex; + align-items: center; +} + + diff --git a/src/core_plugins/metrics/public/less/editor.less b/src/core_plugins/metrics/public/less/editor.less new file mode 100644 index 00000000000000..dac03187dc1461 --- /dev/null +++ b/src/core_plugins/metrics/public/less/editor.less @@ -0,0 +1,426 @@ +@borderRadius: 4px; + +.vis_editor_container { + background: @pageColor; +} + +// general styles +.vis_editor__title { + display: flex; + flex-grow: 1; + align-items: center; + font-size: 20px; + margin-bottom: 20px; + + i.fa { + color: @grayLighter; + margin: 0 10px; + } + + i.fa-pencil { + &:hover { + color: @gray; + } + } + + i.fa-check-square { + color: @esGreen; + &:hover { + color: darken(@esGreen, 10%); + } + } + + input { + padding: 0 10px; + border-radius: 4px; + border: 1px solid @grayLighter; + flex-grow: 1; + } +} +.vis_editor__container { + padding: 10px; + background-color: @white; + display: flex; + flex-direction: column; + flex: 1 1 auto; +} +.vis_editor__label { + color: @gray; + margin: 0 10px; + flex-shrink: 0; + &:first-child { + margin: 0 10px 0 0; + } +} +.vis_editor__input { padding: 8px 10px; border-radius: @borderRadius; border: 1px solid @grayLight; } +.vis_editor__input-grows { .vis_editor__input; flex-grow: 1; } +.vis_editor__input-grows-100 { .vis_editor__input-grows; width: 100%; } +.vis_editor__row { display: flex; align-items: center; } +.vis_editor__item { flex-grow: 1; } +.vis_editor__row_item { flex-grow: 1; margin-right: 10px; } +.vis_editor__subhead { font-size: 12px; color: @gray; margin: 5px 0; } +.vis_editor__subhead-main { font-size: 18px; color: @gray; margin: 10px 10px 5px; } +.vis_editor__note { font-size: 14px; color: @gray; margin: 5px 0 10px 0; } + +// color_picker.js +.vis_editor__color_picker { + display: flex; + align-items: center; + position: relative; +} +.vis_editor__color_picker-swatch { border: 1px solid @grayDark; width: 20px; height: 20px; border-radius: 4px; } +.vis_editor__color_picker-swatch-empty { + .vis_editor__color_picker-swatch; + background-color: transparent; + background-size: 20px 20px; + background-image: repeating-linear-gradient(-45deg, #C00, #C00 2px, transparent 2px, transparent 16px); +} +.vis_editor__color_picker-clear { + margin-left: 5px; + color: #C00; +} +.vis_editor__color_picker-popover { position: absolute; top: 20px; z-index: 2 } +.vis_editor__color_picker-cover { position: fixed; top: 0px; right: 0px; left: 0px; bottom: 0px; } + +// data_format_picker +.vis_editor__data_format_picker-container { + display: flex; + align-items: center; + flex-grow: 1; +} +.vis_editor__data_format_picker-custom_row { + display: flex; + align-items: center; + > .vis_editor__label { margin-left: 10px; } +} + + +// index_pattern.js +.vis_editor__index_pattern-fields { margin-right: 10px; flex-grow: 1; } + +// series.js +// mainRow == .vis_editor__container +.vis_editor__series { + background-color: @white; + padding: 10px; + border-top: 2px solid @lineColor; + &:first-child { + border-top: none; + } + +} +.vis_editor__series-row { + .vis_editor__container; + padding: 0 10px 10px; + display: flex; + flex-direction: column; + flex: 1 1 auto; +} +.vis_editor__series-details { + .vis_editor__row; + flex-grow: 1; + > * { margin-right: 10px; } + > .vis_editor__sort { + cursor: move; + margin-right: 0px; + } +} + +// series_config.js +.vis_editor__series_config-subhead { .vis_editor__subhead; margin: 10px 0 5px; } + +// series_editor.js +.vis_editor__series_editor-container { margin-bottom: 20px; } + +//split.js +.vis_editor__split-container { .vis_editor__row; flex-grow: 1; } +.vis_editor__split-filter { .vis_editor__input; flex-grow: 1; } +.vis_editor__split-selects { .vis_editor__item; } +.vis_editor__split-aggs { flex-grow: 1; } +.vis_editor__split-term_count { .vis_editor__input; width: 50; } + +// vis_picker.js +.vis_editor__vis_picker-container { + display: flex; + align-items: center; +} + +.vis_editor__vis_picker-item { + justify-content: center; + display: flex; + align-items: center; + font-size: 18px; + padding: 5px 0; + margin: 0 10px; + &:hover { + border-bottom: 2px solid @grayDarker; + } + &.selected { + border-bottom: 2px solid @grayDarker; + } +} +.vis_editor__vis_picker-icon { + display: none; + margin-right: 5px; + color: @grayDark; + &:hover, + &.selected { + color: @grayDarker; + } +} +.vis_editor__vis_picker-label { + font-size: 18px; + color: @grayDark; + &:hover, + &.selected { + color: @grayDarker; + } +} + +.vis_editor__vis_picker-controls { + flex: 1 0 auto; + text-align: right; + margin-right: 10px; +} + +// visualization.js +.vis_editor__visualization { + position: relative; + display: flex; + flex-direction: column; + flex: 1 0 auto; + width: 100%; + height: 250px; + line-height: normal; + background-color: @white; +} + +.vis_editor__visualization-draghandle { + text-align: center; + color: @grayLight; + cursor: row-resize; + &:hover { + color: @gray; + } +} + +.vis_editor__visualization-title { + color: @gray; + font-weight: 500; + overflow: hidden; + white-space: nowrap; + font-size: 16px; + margin-bottom: 10px; +} + +// aggs/agg_row +.vis_editor__agg_row-icon { + margin-right: 10px; + color: @gray; + &.last { color: @grayDark } +} + +.vis_editor__series_row { + display: flex; + background-color: @grayLightest; + margin-bottom: 2px; + padding: 10px; + align-items: center; + .vis_editor__label { margin-bottom: 5px; font-size: 12px; } +} + +.vis_editor__agg_row { + .vis_editor__series_row; +} + +.vis_editor__series_row-item { + display: flex; + flex-grow: 1; +} + +.vis_editor__agg_row-item { + .vis_editor__series_row-item; + margin-bottom: 10px; +} + +// aggs/std_deviation.js +.vis_editor__std_deviation-field { + .vis_editor__row_item; + flex-grow: 2; +} +.vis_editor__std_deviation-sigma_item { + margin-right: 10px; +} +.vis_editor__std_deviation-sigma { + .vis_editor__input; + width: 50px; +} + +.vis_editor__percentile_rank_value { + margin-right: 10px; +} + +// aggs/std_sibling.js +.vis_editor__std_sibling-metric { + .vis_editor__row_item; + flex-grow: 2; +} + +// aggs/vis_config +.vis_editor__vis_config-row { + .vis_editor__row; + margin: 10px 0; +} + +// aggs/series_config +.vis_editor__series_config-container { + background-color: @grayLightest; + padding: 10px; +} + +.vis_editor__series_config-row { + .vis_editor__row; + padding: 5px 0; + font-size: 12px; +} + +.vis_editor__percentiles, +.vis_editor__variables { + .vis_editor__row_item; + margin: 10px 0; +} +.vis_editor__calc_vars { + // background-color: @white; + // padding: 10px; + margin-right: 10px; + margin-bottom: 2px; +} + +.vis_editor__percentiles-row, +.vis_editor__calc_vars-row { + display: flex; + margin-bottom: 10px; + align-items: center; + &:last-child { + margin-bottom: 0; + } +} + +.vis_editor__calc_vars-name { + margin-right: 10px; +} + +.vis_editor__calc_vars-var{ + flex-grow: 1; + margin-right: 10px; +} + +.vis_editor__percentiles-content { + display: flex; + align-items: center; + flex-grow: 1; + margin-right: 10px; +} + +.vis_editor__markdown { + display: flex; + background-color: @white; + min-height: 500px; +} + +.vis_editor__markdown-editor { + border: 2px solid @lineColor; + width: 50%; + flex: 1 0 auto; +} + +.vis_editor__markdown-variables { + padding: 10px; + flex: 1 0 auto; + max-height: 500px; + overflow: auto; + width: 50%; + .table a { text-decoration: none } + pre { + border-radius: 0; + border: none; + } +} + +.vis_editor__markdown-code-desc { + margin-bottom: 10px; +} + +.vis_editor__ace-editor { + border: 2px solid @lineColor; +} + +.vis_editor__split-filters { + flex-grow: 1; + display: flex; + padding: 10px 20px; + flex-direction: column; +} + +.vis_editor__split-filter-row { + display: flex; + flex-grow: 1; + margin-bottom: 10px; + align-items: center; + &:last-child { + margin-bottom: 0; + } +} + +.vis_editor__split-filter-item { + flex-grow: 1; + margin-right: 10px; +} + +.vis_editor__split-filter-color { + margin-right: 10px; +} + +.vis_editor__annotations-color, +.vis_editor__annotations-controls { + flex-shrink: 0; +} + +.vis_editor__annotations-row { + display: flex; + padding: 10px; + background-color: @lineColor; + margin-bottom: 2px; +} + +.vis_editor__annotations-missing { + padding: 30px 10px; + font-size: 16px; + p { font-size: 16px; } + text-align: center; +} + +.vis_editor__annotations-content { + margin: 0 10px; + flex-grow: 1; + .vis_editor__row { + margin-bottom: 10px; + } + .vis_editor__row-item { + flex-grow: 1; + margin-left: 10px; + &:first-child { + margin-left: 0; + } + .vis_editor__label { + margin-bottom: 4px; + } + } +} + +.vis_editor__icon_select-option { + margin: 0 5px; +} +.vis_editor__icon_select-value { + margin: 0 5px 0 0; +} diff --git a/src/core_plugins/metrics/public/less/error.less b/src/core_plugins/metrics/public/less/error.less new file mode 100644 index 00000000000000..d8bc20c726e13e --- /dev/null +++ b/src/core_plugins/metrics/public/less/error.less @@ -0,0 +1,34 @@ +.metrics_error { + display: flex; + flex-direction: column; + flex: 1 0 auto; + background-color: #FFD9D9; + color: #C00; + justify-content: center; + padding: 20px +} + +.merics_error__title { + text-align: center; + font-size: 18px; + font-weight: bold; +} + +.metrics_error__additional { + margin-top: 10px; + padding: 0 20px; +} + +.metrics_error__reason { + text-align: center; +} + +.metrics_error__stack { + margin-top: 10px; + color: #FFF; + border: 10px solid #FFF; + background-color: #000; + font-family: "Courier New", Courier, monospace; + white-space: pre; + padding: 10px; +} diff --git a/src/core_plugins/metrics/public/less/kbn_tabs.less b/src/core_plugins/metrics/public/less/kbn_tabs.less new file mode 100644 index 00000000000000..444d49a9d1eba4 --- /dev/null +++ b/src/core_plugins/metrics/public/less/kbn_tabs.less @@ -0,0 +1,29 @@ +.kbnTabs { + display: flex; +} + +.kbnTabs__tab { + margin: 0px 10px; + padding: 5px 0px; + color: @grayDark; + font-size: 18px; +} + +.kbnTabs__tab-active { + .kbnTabs__tab; + color: @grayDarker; + border-bottom: 2px solid @grayDarker; +} + +.kbnTabs__tab:hover { + .kbnTabs__tab-active; +} + +.kbnTabs.sm { + .kbnTabs__tab-active, + .kbnTabs__tab { + font-size: 14px; + padding: 2px 0; + } +} + diff --git a/src/core_plugins/metrics/public/less/main.less b/src/core_plugins/metrics/public/less/main.less new file mode 100644 index 00000000000000..5726030e334e17 --- /dev/null +++ b/src/core_plugins/metrics/public/less/main.less @@ -0,0 +1,40 @@ +@black: black; +@grayDarkest: #222; +@grayDarker: #333; +@grayDark: #666; +@gray: #999; +@grayLight: #CCC; +@grayLighter: #DDD; +@grayLightest: #EEE; +@white: white; + +@background: #FFF; +@navBarBackground: #DDD; +@lineColor: #EEE; +@textColor: #999; +@disabledColor: #CCC; +@valueColor: #666; +@topNavColor: #E4E4E4; +@pageColor: #F6F6F6; + +@kibanaGray: #95a5a6; +@esBlue: #6eadc1; +@esRed: #d76051; +@esYellow: #fbce47; +@esGreen: #80c383; +@esPink: #e8488b; +@esPurple: #9980b2; + +@esAltGreen: #8ac336; +@esCyan: #59c6c5; + +@import './misc.less'; +@import './editor.less'; +@import './kbn_tabs.less'; +@import './color_rules.less'; +@import './markdown.less'; +@import './sortable.less'; +@import './color_picker.less'; +@import './error.less'; + + diff --git a/src/core_plugins/metrics/public/less/markdown.less b/src/core_plugins/metrics/public/less/markdown.less new file mode 100644 index 00000000000000..b995f2a177477e --- /dev/null +++ b/src/core_plugins/metrics/public/less/markdown.less @@ -0,0 +1,60 @@ +.thorMarkdown { + display: flex; + flex-direction: column; + flex: 1 0 auto; + position: relative; +} + +.thorMarkdown__content { + display: flex; + flex-direction: column; + flex: 1 0 auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + @import './type.less'; + color: rgba(0,0,0,1); + pre, code, tt { + background-color: rgba(0,0,0,0.1); + color: red; + code { + color: rgba(0,0,0,1); + background-color: transparent; + } + border: none; + } + &.middle { + justify-content: center; + } + &.bottom { + justify-content: flex-end; + } + &.scrolling { + overflow: auto; + } +} + +.thorMarkdown.reversed { + .thorMarkdown__content { + .table > thead > tr > th { + color: rgba(255,255,255,0.5); + border-bottom: 2px solid rgba(255,255,255,0.2); + } + .table > tbody > tr > td { + border-top: 1px solid rgba(255,255,255,0.2); + } + color: rgba(255,255,255,1); + pre, code, tt { + background-color: rgba(255,255,255,0.2); + color: #ffa5a8; + code { + color: rgba(255,255,255,1); + background-color: transparent; + } + border: none; + } + } +} diff --git a/src/core_plugins/metrics/public/less/misc.less b/src/core_plugins/metrics/public/less/misc.less new file mode 100644 index 00000000000000..03928de21b65bf --- /dev/null +++ b/src/core_plugins/metrics/public/less/misc.less @@ -0,0 +1,104 @@ +.thor__input { + padding: 7px 10px; + border-radius: 4px; + border: 1px solid @grayLighter; + &:focus { + outline: none; + box-shadow: 0 0 2px @grayLight; + } +} + +.thor__yes_no { + label { + font-weight: normal; + margin-right: 10px; + input { margin-right: 5px; } + } +} + +.thor__button { + display: inline-block; + vertical-align: middle; + text-align: center; + cursor: pointer; + white-space: nowrap; + border: 1px solid transparent; + background-color: transparent; + padding: 4px 8px; + border-radius: 4px; + margin: 0 0 0 10px; + &.sm { + padding: 2px 6px; + font-size: 0.8em; + margin-left: 5px; + } + &.md { + padding: 3px 7px; + font-size: 0.9em; + margin-left: 5px; + } +} + +.create-buttons(@name; @color) { + .thor__button-outlined-@{name} { + .thor__button; + border-color: @color; + color: @color !important; + &:hover { + border-color: darken(@color, 10%); + color: darken(@color, 10%) !important; + } + } + .thor__button-solid-@{name} { + .thor__button; + background-color: @color; + color: @white !important; + &:hover { + background-color: darken(@color, 10%); + color: @white !important; + } + } +} + + +.create-buttons(gray, @kibanaGray); +.create-buttons(default, @esBlue); +.create-buttons(primary, @esGreen); +.create-buttons(danger, @esRed); + +.pui-tooltip { font-size: 12px !important; } + +.add_delete__buttons { + > * { + margin-left: 5px; + } + .disabled { + color: @gray; + border-color: @gray; + } +} + +.dashboard__visualization { + display: flex; + flex-direction: column; + flex: 1 0 auto; + padding: 10px; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +.dashboard__visualization-title { + color: rgba(0,0,0,0.6); + font-weight: 500; + overflow: hidden; + white-space: nowrap; + font-size: 16px; + margin-bottom: 10px; + flex: 0 0 auto; + &.reversed { + color: rgba(255,255,255,0.8); + } +} diff --git a/src/core_plugins/metrics/public/less/sortable.less b/src/core_plugins/metrics/public/less/sortable.less new file mode 100644 index 00000000000000..b1b043ca0207ff --- /dev/null +++ b/src/core_plugins/metrics/public/less/sortable.less @@ -0,0 +1,38 @@ +.ui-sortable { + display: block; + position: relative; + overflow: visible; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.ui-sortable:before, +.ui-sortable:after{ + content: " "; + display: table; +} + +.ui-sortable:after{ + clear: both; +} + +.ui-sortable .ui-sortable-item { +} + +.ui-sortable .ui-sortable-item.ui-sortable-dragging { + position: absolute; + z-index: 1688; +} + +.ui-sortable .ui-sortable-placeholder { + display: none; +} + +.ui-sortable .ui-sortable-placeholder.visible { + display: block; + opacity: 0.5; + z-index: -1; +} + + diff --git a/src/core_plugins/metrics/public/less/type.less b/src/core_plugins/metrics/public/less/type.less new file mode 100644 index 00000000000000..0dc3da0a3e4292 --- /dev/null +++ b/src/core_plugins/metrics/public/less/type.less @@ -0,0 +1,94 @@ +pre, code, tt { + border-radius: 0; + font: 1em/1.5em 'Andale Mono', 'Lucida Console', monospace; +} +h1 { font-size: 30px; } +h2 { font-size: 26px; } +h3 { font-size: 22px; } +h4 { font-size: 18px; } +h5 { font-size: 16px; } +h6 { font-size: 16px; } + +h1, h2, h3, h4, h5, h6, b, strong { + margin:0 0 15px 0; + font-weight: bold; +} +em, i, dfn { + font-style: italic; +} +dfn { + font-weight:bold; +} +p, code, pre, kbd { + margin:0 0 15px 0; +} +blockquote { + margin:0 15px 15px 15px; +} +cite { + font-style: italic; +} +li ul, li ol { + margin:0 15px; +} +ul, ol { + margin:0 15px 15px 15px; +} +ul { + list-style-type:disc; +} +ol { + list-style-type:decimal; +} +ol ol { + list-style: upper-alpha; +} +ol ol ol { + list-style: lower-roman; +} +ol ol ol ol { + list-style: lower-alpha; +} +dl { + margin:0 0 15px 0; +} +dl dt { + font-weight:bold; +} +dd { + margin-left:1.5em; +} +table { + margin-bottom:1.4em; + width:100%; +} +th { + font-weight:bold; +} +th, td, caption { + padding:4px 15px 4px 5px; +} +tfoot { + font-style:italic; +} +sup, sub { + line-height:0; +} +abbr, acronym { + border-bottom: 1px dotted; +} +address { + margin:0 0 15px; + font-style:italic; +} +del { + text-decoration: line-through; +} +pre { + margin: 15px 0; + white-space: pre; +} +img { + max-width: 100%; +} + diff --git a/src/core_plugins/metrics/public/lib/__tests__/add_scope.js b/src/core_plugins/metrics/public/lib/__tests__/add_scope.js new file mode 100644 index 00000000000000..fb5b4135670072 --- /dev/null +++ b/src/core_plugins/metrics/public/lib/__tests__/add_scope.js @@ -0,0 +1,58 @@ +// import React from 'react'; +// import { expect } from 'chai'; +// import { shallow } from 'enzyme'; +// import sinon from 'sinon'; +// import addScope from '../add_scope'; + +// const Component = React.createClass({ +// render() { +// return (
); +// } +// }); + +// describe('addScope()', () => { + +// let unsubStub; +// let watchCollectionStub; +// let $scope; + +// beforeEach(() => { +// unsubStub = sinon.stub(); +// watchCollectionStub = sinon.stub().returns(unsubStub); +// $scope = { +// $watchCollection: watchCollectionStub, +// testOne: 1, +// testTwo: 2 +// }; +// }); + +// it('adds $scope variables as props to wrapped component', () => { +// const WrappedComponent = addScope(Component, $scope, ['testOne', 'testTwo']); +// const wrapper = shallow(); +// expect(wrapper.state('testOne')).to.equal(1); +// expect(wrapper.state('testTwo')).to.equal(2); +// }); + +// it('calls $scope.$watchCollection on each scoped item', () => { +// const WrappedComponent = addScope(Component, $scope, ['testOne', 'testTwo']); +// const wrapper = shallow(); +// expect(watchCollectionStub.calledTwice).to.equal(true); +// expect(watchCollectionStub.firstCall.args[0]).to.equal('testOne'); +// expect(watchCollectionStub.secondCall.args[0]).to.equal('testTwo'); +// }); + +// it('unsubscribes from watches', () => { +// const WrappedComponent = addScope(Component, $scope, ['testOne', 'testTwo']); +// const wrapper = shallow(); +// wrapper.unmount(); +// expect(unsubStub.calledTwice).to.equal(true); +// }); + +// it('updates state when watch is called', () => { +// const WrappedComponent = addScope(Component, $scope, ['testOne']); +// const wrapper = shallow(); +// watchCollectionStub.firstCall.args[1].call(null, 3); +// expect(wrapper.state('testOne')).to.equal(3); +// }); + +// }); diff --git a/src/core_plugins/metrics/public/lib/__tests__/create_brush_handler.js b/src/core_plugins/metrics/public/lib/__tests__/create_brush_handler.js new file mode 100644 index 00000000000000..d57f05e4bbf7fe --- /dev/null +++ b/src/core_plugins/metrics/public/lib/__tests__/create_brush_handler.js @@ -0,0 +1,33 @@ +import createBrushHandler from '../create_brush_handler'; +import sinon from 'sinon'; +import moment from 'moment'; +import { expect } from 'chai'; + +describe('createBrushHandler', () => { + + let evalAsyncStub; + let $scope; + let timefilter; + let fn; + let range; + + beforeEach(() => { + evalAsyncStub = sinon.stub().yields(); + $scope = { $evalAsync: evalAsyncStub }; + timefilter = { time: {} }; + fn = createBrushHandler($scope, timefilter); + range = { xaxis: { from: '2017-01-01T00:00:00Z', to: '2017-01-01T00:10:00Z' } }; + fn(range); + }); + + it('returns brushHandler() that calls $scope.$evalAsync()', () => { + expect(evalAsyncStub.calledOnce).to.equal(true); + }); + + it('returns brushHandler() that updates timefilter', () => { + expect(timefilter.time.from).to.equal(moment(range.xaxis.from).toISOString()); + expect(timefilter.time.to).to.equal(moment(range.xaxis.to).toISOString()); + expect(timefilter.time.mode).to.equal('absolute'); + }); + +}); diff --git a/src/core_plugins/metrics/public/lib/add_scope.js b/src/core_plugins/metrics/public/lib/add_scope.js new file mode 100644 index 00000000000000..047501f8fba914 --- /dev/null +++ b/src/core_plugins/metrics/public/lib/add_scope.js @@ -0,0 +1,33 @@ +import React from 'react'; +export default function addScope(WrappedComponent, $scope, addToState = []) { + return React.createClass({ + + getInitialState() { + const state = {}; + addToState.forEach(key => { + state[key] = $scope[key]; + }); + return state; + }, + + componentWillMount() { + this.unsubs = addToState.map(key => { + return $scope.$watchCollection(key, newValue => { + const newState = {}; + newState[key] = newValue; + this.setState(newState); + }); + }); + }, + + componentWillUnmount() { + this.unsubs.forEach(fn => fn()); + }, + + render() { + return ( + + ); + } + }); +} diff --git a/src/core_plugins/metrics/public/lib/create_brush_handler.js b/src/core_plugins/metrics/public/lib/create_brush_handler.js new file mode 100644 index 00000000000000..8b6b5bf3152316 --- /dev/null +++ b/src/core_plugins/metrics/public/lib/create_brush_handler.js @@ -0,0 +1,8 @@ +import moment from 'moment'; +export default ($scope, timefilter) => ranges => { + $scope.$evalAsync(() => { + timefilter.time.from = moment(ranges.xaxis.from).toISOString(); + timefilter.time.to = moment(ranges.xaxis.to).toISOString(); + timefilter.time.mode = 'absolute'; + }); +}; diff --git a/src/core_plugins/metrics/public/lib/create_new_panel.js b/src/core_plugins/metrics/public/lib/create_new_panel.js new file mode 100644 index 00000000000000..181e63b9eee434 --- /dev/null +++ b/src/core_plugins/metrics/public/lib/create_new_panel.js @@ -0,0 +1,18 @@ +import newSeriesFn from '../components/lib/new_series_fn'; +import uuid from 'node-uuid'; +export default () => { + const id = uuid.v1(); + return { + id, + type: 'timeseries', + series: [ + newSeriesFn() + ], + time_field: '@timestamp', + index_pattern: '*', + interval: 'auto', + axis_position: 'left', + axis_formatter: 'number', + show_legend: 1 + }; +}; diff --git a/src/core_plugins/metrics/public/lib/fetch.js b/src/core_plugins/metrics/public/lib/fetch.js new file mode 100644 index 00000000000000..bfc9e60570a0ea --- /dev/null +++ b/src/core_plugins/metrics/public/lib/fetch.js @@ -0,0 +1,32 @@ +export default ( + timefilter, + Private, + Notifier, + $http +) => { + const dashboardContext = Private(require('plugins/timelion/services/dashboard_context')); + const notify = new Notifier({ location: 'Metrics' }); + return $scope => () => { + const panel = $scope.model; + if (panel && panel.id) { + const params = { + timerange: timefilter.getBounds(), + filters: [dashboardContext()], + panels: [panel] + }; + + return $http.post('../api/metrics/vis/data', params) + .success(resp => { + $scope.visData = resp; + }) + .error(resp => { + $scope.visData = {}; + const err = new Error(resp.message); + err.stack = resp.stack; + notify.error(err); + }); + } + return Promise.resolve(); + }; +}; + diff --git a/src/core_plugins/metrics/public/lib/fetch_fields.js b/src/core_plugins/metrics/public/lib/fetch_fields.js new file mode 100644 index 00000000000000..8f0949ed34a7fa --- /dev/null +++ b/src/core_plugins/metrics/public/lib/fetch_fields.js @@ -0,0 +1,25 @@ +export default ( + Notifier, + $http +) => { + const notify = new Notifier({ location: 'Metrics' }); + return $scope => (indexPatterns = ['*']) => { + if (!Array.isArray(indexPatterns)) indexPatterns = [indexPatterns]; + return Promise.all(indexPatterns.map(pattern => { + return $http.get(`../api/metrics/fields?index=${pattern}`) + .success(resp => { + if (!$scope.fields) $scope.fields = {}; + if (resp.length && pattern) { + $scope.fields[pattern] = resp; + } + }) + .error(resp => { + $scope.visData = {}; + const err = new Error(resp.message); + err.stack = resp.stack; + notify.error(err); + }); + })); + }; +}; + diff --git a/src/core_plugins/metrics/public/services/__tests__/executor_provider.js b/src/core_plugins/metrics/public/services/__tests__/executor_provider.js new file mode 100644 index 00000000000000..62ad9125544a9a --- /dev/null +++ b/src/core_plugins/metrics/public/services/__tests__/executor_provider.js @@ -0,0 +1,102 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import executorProvider from '../executor_provider'; +import EventEmitter from 'events'; +import Promise from 'bluebird'; + +describe('$executor service', () => { + + let executor; + let timefilter; + let $timeout; + let execute; + let onSpy; + let offSpy; + + beforeEach(() => { + + $timeout = sinon.spy(setTimeout); + $timeout.cancel = (id) => clearTimeout(id); + + timefilter = new EventEmitter(); + onSpy = sinon.spy((...args) => timefilter.addListener(...args)); + offSpy = sinon.spy((...args) => timefilter.removeListener(...args)); + + timefilter.on = onSpy; + timefilter.off = offSpy; + + timefilter.refreshInterval = { + pause: false, + value: 0 + }; + executor = executorProvider(Promise, $timeout, timefilter); + }); + + afterEach(() => executor.destroy()); + + it('should register listener for fetch upon start', () => { + executor.start(); + expect(onSpy.calledTwice).to.equal(true); + expect(onSpy.firstCall.args[0]).to.equal('fetch'); + expect(onSpy.firstCall.args[1].name).to.equal('reFetch'); + }); + + it('should register listener for update upon start', () => { + executor.start(); + expect(onSpy.calledTwice).to.equal(true); + expect(onSpy.secondCall.args[0]).to.equal('update'); + expect(onSpy.secondCall.args[1].name).to.equal('killIfPaused'); + }); + + it('should not call $timeout if the timefilter is not paused and set to zero', () => { + executor.start(); + expect($timeout.callCount).to.equal(0); + }); + + it('should call $timeout if the timefilter is not paused and set to 1000ms', () => { + timefilter.refreshInterval.value = 1000; + executor.start(); + expect($timeout.callCount).to.equal(1); + }); + + it('should execute function if ingorePause is passed (interval set to 1000ms)', (done) => { + timefilter.refreshInterval.value = 1000; + executor.register({ execute: () => done() }); + executor.start({ ignorePaused: true }); + }); + + it('should execute function if timefilter is not paused and interval set to 1000ms', (done) => { + timefilter.refreshInterval.value = 1000; + executor.register({ execute: () => done() }); + executor.start(); + }); + + it('should execute function multiple times', (done) => { + let calls = 0; + timefilter.refreshInterval.value = 10; + executor.register({ execute: () => { + if (calls++ > 1) done(); + return Promise.resolve(); + } }); + executor.start(); + }); + + it('should call handleResponse', (done) => { + timefilter.refreshInterval.value = 10; + executor.register({ + execute: () => Promise.resolve(), + handleResponse: () => done() + }); + executor.start(); + }); + + it('should call handleError', (done) => { + timefilter.refreshInterval.value = 10; + executor.register({ + execute: () => Promise.reject(), + handleError: () => done() + }); + executor.start(); + }); + +}); diff --git a/src/core_plugins/metrics/public/services/executor.js b/src/core_plugins/metrics/public/services/executor.js new file mode 100644 index 00000000000000..98b5e06586e6e9 --- /dev/null +++ b/src/core_plugins/metrics/public/services/executor.js @@ -0,0 +1,4 @@ +import uiModules from 'ui/modules'; +import executorProvider from './executor_provider'; +const uiModule = uiModules.get('kibana/metrics_vis/executor', []); +uiModule.service('metricsExecutor', executorProvider); diff --git a/src/core_plugins/metrics/public/services/executor_provider.js b/src/core_plugins/metrics/public/services/executor_provider.js new file mode 100644 index 00000000000000..b59dc33e98b942 --- /dev/null +++ b/src/core_plugins/metrics/public/services/executor_provider.js @@ -0,0 +1,110 @@ +import _ from 'lodash'; +export default function executorProvider(Promise, $timeout, timefilter) { + + const queue = []; + let executionTimer; + let ignorePaused = false; + + /** + * Resets the timer to start again + * @returns {void} + */ + function reset() { + cancel(); + start(); + } + + function killTimer() { + if (executionTimer) $timeout.cancel(executionTimer); + } + + /** + * Cancels the execution timer + * @returns {void} + */ + function cancel() { + killTimer(); + timefilter.off('update', killIfPaused); + timefilter.off('fetch', reFetch); + } + + /** + * Registers a service with the executor + * @param {object} service The service to register + * @returns {void} + */ + function register(service) { + queue.push(service); + } + + /** + * Stops the executor and empties the service queue + * @returns {void} + */ + function destroy() { + cancel(); + ignorePaused = false; + queue.splice(0, queue.length); + } + + /** + * Runs the queue (all at once) + * @returns {Promise} a promise of all the services + */ + function run() { + const noop = () => Promise.resolve(); + return Promise.all(queue.map((service) => { + return service.execute() + .then(service.handleResponse || noop) + .catch(service.handleError || noop); + })) + .finally(reset); + } + + function reFetch(_changes) { + cancel(); + run(); + } + + function killIfPaused() { + if (timefilter.refreshInterval.pause) { + killTimer(); + } + } + + /** + * Starts the executor service if the timefilter is not paused + * @returns {void} + */ + function start() { + timefilter.on('fetch', reFetch); + timefilter.on('update', killIfPaused); + if ((ignorePaused || timefilter.refreshInterval.pause === false) && timefilter.refreshInterval.value > 0) { + executionTimer = $timeout(run, timefilter.refreshInterval.value); + } + } + + /** + * Expose the methods + */ + return { + register, + start(options = {}) { + options = _.defaults(options, { + ignorePaused: false, + now: false + }); + if (options.now) { + return run(); + } + if (options.ignorePaused) { + ignorePaused = options.ignorePaused; + } + start(); + }, + run, + destroy, + reset, + cancel + }; +} diff --git a/src/core_plugins/metrics/public/visualizations/components/annotation.js b/src/core_plugins/metrics/public/visualizations/components/annotation.js new file mode 100644 index 00000000000000..c1af84500cc9e4 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/components/annotation.js @@ -0,0 +1,73 @@ +import React, { Component, PropTypes } from 'react'; +import moment from 'moment'; +import reactcss from 'reactcss'; +class Annotation extends Component { + + constructor(props) { + super(props); + this.state = { + showTooltip: false + }; + this.handleMouseOut = this.handleMouseOut.bind(this); + this.handleMouseOver = this.handleMouseOver.bind(this); + } + + renderTooltip() { + if (!this.state.showTooltip) return null; + const [ timestamp, messageSource ] = this.props.series; + const reversed = this.props.reversed ? '-reversed' : ''; + const messages = messageSource.map((message, i) => { + return ( +
{ message }
+ ); + }); + return ( +
+
+
{ moment(timestamp).format('lll') }
+ { messages } +
+
+
+ ); + } + + handleMouseOver() { + this.setState({ showTooltip: true }); + } + + handleMouseOut() { + this.setState({ showTooltip: false }); + } + + render() { + const { color, plot, icon, series } = this.props; + const offset = plot.pointOffset({ x: series[0], y: 0 }); + const style = { left: offset.left - 6, bottom: 0, top: 5 }; + const tooltip = this.renderTooltip(); + return( +
+
+
+ + { tooltip } +
+
+ ); + } + +} + +Annotation.propTypes = { + series: PropTypes.array, + icon: PropTypes.string, + color: PropTypes.string, + plot: PropTypes.object, + reversed: PropTypes.bool +}; + +export default Annotation; diff --git a/src/core_plugins/metrics/public/visualizations/components/flot_chart.js b/src/core_plugins/metrics/public/visualizations/components/flot_chart.js new file mode 100644 index 00000000000000..fd139a908ea59b --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/components/flot_chart.js @@ -0,0 +1,261 @@ +import React, { Component, PropTypes } from 'react'; +import { findDOMNode } from 'react-dom'; +import _ from 'lodash'; +import $ from '../lib/flot'; +import eventBus from '../lib/events'; +import ResizeAware from 'simianhacker-react-resize-aware'; +import calculateBarWidth from '../lib/calculate_bar_width'; +import colors from '../lib/colors'; + +class FlotChart extends Component { + + shouldComponentUpdate(props, state) { + if (!this.plot) return true; + if (props.reversed !== this.props.reversed) { + return true; + } + if (props.yaxes && this.props.yaxes) { + // We need to rerender if the axis change + const valuesChanged = props.yaxes.some((axis, i) => { + if (this.props.yaxes[i]) { + return axis.position !== this.props.yaxes[i].position || + axis.max !== this.props.yaxes[i].max || + axis.min !== this.props.yaxes[i].min; + } + }); + if (props.yaxes.length !== this.props.yaxes.length || valuesChanged) { + return true; + } + } + return false; + } + + shutdownChart() { + if (!this.plot) return; + $(this.target).unbind('plothover', this.props.plothover); + if (this.props.onMouseOver) $(this.target).on('plothover', this.handleMouseOver); + if (this.props.onMouseLeave) $(this.target).on('mouseleave', this.handleMouseLeave); + if (this.props.onBrush) $(this.target).off('plotselected', this.brushChart); + this.plot.shutdown(); + if (this.props.crosshair) { + $(this.target).off('plothover', this.handlePlotover); + eventBus.off('thorPlotover', this.handleThorPlotover); + eventBus.off('thorPlotleave', this.handleThorPlotleave); + } + findDOMNode(this.resize).removeEventListener('resize', this.handleResize); + } + + componentWillUnmount() { + this.shutdownChart(); + } + + filterByShow(show) { + if (show) { + return (metric) => { + return show.some(id => _.startsWith(id, metric.id)); + }; + } + return (metric) => true; + } + + componentWillReceiveProps(newProps) { + if (this.plot) { + const { series, markings } = newProps; + const options = this.plot.getOptions(); + _.set(options, 'series.bars.barWidth', calculateBarWidth(series)); + this.plot.setData(this.calculateData(series, newProps.show)); + this.plot.setupGrid(); + this.plot.draw(); + if (!_.isEqual(this.props.series, series)) this.handleDraw(this.plot); + } else { + this.renderChart(); + } + } + + componentDidMount() { + this.renderChart(); + } + + componentDidUpdate() { + this.shutdownChart(); + this.renderChart(); + } + + calculateData(data, show) { + const series = []; + return _(data) + .filter(this.filterByShow(show)) + .map((set) => { + if (_.isPlainObject(set)) { + return set; + } + return { + color: '#990000', + data: set + }; + }).reverse().value(); + } + + handleDraw(plot, canvasContext) { + if (this.props.onDraw) this.props.onDraw(plot); + } + + getOptions() { + const yaxes = this.props.yaxes || [{}]; + + const lineColor = this.props.reversed ? colors.lineColorReversed : colors.lineColor; + const textColor = this.props.reversed ? colors.textColorReversed : colors.textColor; + const valueColor = this.props.reversed ? colors.valueColorReversed : colors.valueColor; + + const opts = { + legend: { show: false }, + yaxes: yaxes, + yaxis: { + color: lineColor, + font: { color: textColor }, + tickFormatter: this.props.tickFormatter + }, + xaxis: { + color: lineColor, + timezone: 'browser', + mode: 'time', + font: { color: textColor } + }, + series: { + shadowSize: 0 + }, + grid: { + margin: 0, + borderWidth: 1, + borderColor: lineColor, + hoverable: true, + mouseActiveRadius: 200, + } + }; + + if (this.props.crosshair) { + _.set(opts, 'crosshair', { + mode: 'x', + color: this.props.reversed ? '#FFF' : '#000', + lineWidth: 1 + }); + } + + if (this.props.onBrush) { + _.set(opts, 'selection', { mode: 'x', color: textColor }); + } + _.set(opts, 'series.bars.barWidth', calculateBarWidth(this.props.series)); + return _.assign(opts, this.props.options); + } + + renderChart() { + const resize = findDOMNode(this.resize); + + if (resize.clientWidth > 0 && resize.clientHeight > 0) { + const { series } = this.props; + const parent = $(this.target.parentElement); + const data = this.calculateData(series, this.props.show); + + this.plot = $.plot(this.target, data, this.getOptions()); + this.handleDraw(this.plot); + + this.handleResize = (e) => { + const resize = findDOMNode(this.resize); + if (resize && resize.clientHeight > 0 && resize.clientHeight > 0) { + if (!this.plot) return; + this.plot.resize(); + this.plot.setupGrid(); + this.plot.draw(); + this.handleDraw(this.plot); + } + }; + + _.defer(() => this.handleResize()); + findDOMNode(this.resize).addEventListener('resize', this.handleResize); + + + this.handleMouseOver = (...args) => { + if (this.props.onMouseOver) this.props.onMouseOver(...args, this.plot); + }; + + this.handleMouseLeave = (...args) => { + if (this.props.onMouseLeave) this.props.onMouseLeave(...args, this.plot); + }; + + $(this.target).on('plothover', this.handleMouseOver); + $(this.target).on('mouseleave', this.handleMouseLeave); + + if (this.props.crosshair) { + + + this.handleThorPlotover = (e, pos, item, originalPlot) => { + if (this.plot !== originalPlot) { + this.plot.setCrosshair({ x: _.get(pos, 'x') }); + this.props.plothover(e, pos, item); + } + }; + + this.handlePlotover = (e, pos, item) => eventBus.trigger('thorPlotover', [pos, item, this.plot]); + this.handlePlotleave = (e) => eventBus.trigger('thorPlotleave'); + this.handleThorPlotleave = (e) => { + this.plot.clearCrosshair(); + if (this.props.plothover) this.props.plothover(e); + }; + + $(this.target).on('plothover', this.handlePlotover); + $(this.target).on('mouseleave', this.handlePlotleave); + eventBus.on('thorPlotover', this.handleThorPlotover); + eventBus.on('thorPlotleave', this.handleThorPlotleave); + } + + if (_.isFunction(this.props.plothover)) { + $(this.target).bind('plothover', this.props.plothover); + } + + $(this.target).on('mouseleave', (e) => { + eventBus.trigger('thorPlotleave'); + }); + + if (_.isFunction(this.props.onBrush)) { + this.brushChart = (e, ranges) => { + this.props.onBrush(ranges); + this.plot.clearSelection(); + }; + + $(this.target).on('plotselected', this.brushChart); + } + } + } + + render() { + const style = { + position: 'relative', + display: 'flex', + rowDirection: 'column', + flex: '1 0 auto', + }; + return ( + this.resize = el} style={style}> +
this.target = el} style={style}/> + ); + } + +} + +FlotChart.propTypes = { + crosshair: PropTypes.bool, + onBrush: PropTypes.func, + onPlotCreate: PropTypes.func, + onMouseOver: PropTypes.func, + onMouseLeave: PropTypes.func, + options: PropTypes.object, + plothover: PropTypes.func, + reversed: PropTypes.bool, + series: PropTypes.array, + show: PropTypes.array, + tickFormatter: PropTypes.func, + yaxes: PropTypes.array, +}; + +export default FlotChart; + diff --git a/src/core_plugins/metrics/public/visualizations/components/gauge.js b/src/core_plugins/metrics/public/visualizations/components/gauge.js new file mode 100644 index 00000000000000..eaf624aeec74b5 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/components/gauge.js @@ -0,0 +1,199 @@ +import _ from 'lodash'; +import numeral from 'numeral'; +import React, { Component, PropTypes } from 'react'; +import $ from '../lib/flot'; +import getLastValue from '../lib/get_last_value'; +import getValueBy from '../lib/get_value_by'; +import ResizeAware from 'simianhacker-react-resize-aware'; +import GaugeVis from './gauge_vis'; +import { findDOMNode } from 'react-dom'; +import reactcss from 'reactcss'; + +class Gauge extends Component { + + constructor(props) { + super(props); + this.state = { + scale: 1, + top: 0, + left: 0, + translateX: 1, + translateY: 1 + }; + + this.handleResize = this.handleResize.bind(this); + } + + calculateCorrdinates() { + const inner = findDOMNode(this.inner); + const resize = findDOMNode(this.resize); + let scale = this.state.scale; + + if (!inner || !resize) return; + + // Let's start by scaling to the largest dimension + if (resize.clientWidth - resize.clientHeight < 0) { + scale = resize.clientWidth / inner.clientWidth; + } else { + scale = resize.clientHeight / inner.clientHeight; + } + let [ newWidth, newHeight ] = this.calcDimensions(inner, scale); + + // Now we need to check to see if it will still fit + if (newWidth > resize.clientWidth) { + scale = resize.clientWidth / inner.clientWidth; + } + if (newHeight > resize.clientHeight) { + scale = resize.clientHeight / inner.clientHeight; + } + + // Calculate the final dimensions + [ newWidth, newHeight ] = this.calcDimensions(inner, scale); + + // Because scale is middle out we need to translate + // the new X,Y corrdinates + const translateX = (newWidth - inner.clientWidth) / 2; + const translateY = (newHeight - inner.clientHeight) / 2; + + // Center up and down + const top = Math.floor((resize.clientHeight - newHeight) / 2); + const left = Math.floor((resize.clientWidth - newWidth) / 2); + + return { scale, top, left, translateY, translateX }; + } + + componentDidMount() { + const resize = findDOMNode(this.resize); + if (!resize) return; + resize.addEventListener('resize', this.handleResize); + this.handleResize(); + } + + componentWillUnmount() { + const resize = findDOMNode(this.resize); + if (!resize) return; + resize.removeEventListener('resize', this.handleResize); + } + + // When the component updates it might need to be resized so we need to + // recalculate the corrdinates and only update if things changed a little. THis + // happens when the number is too wide or you add a new series. + componentDidUpdate() { + const newState = this.calculateCorrdinates(); + if (newState && !_.isEqual(newState, this.state)) { + this.setState(newState); + } + } + + calcDimensions(el, scale) { + const newWidth = Math.floor(el.clientWidth * scale); + const newHeight = Math.floor(el.clientHeight * scale); + return [newWidth, newHeight]; + } + + handleResize() { + // Bingo! + const newState = this.calculateCorrdinates(); + newState && this.setState(newState); + } + + render() { + const { metric, type } = this.props; + const { scale, translateX, translateY, top, left } = this.state; + const value = metric && getLastValue(metric.data, 5) || 0; + const max = metric && getValueBy('max', metric.data) || 1; + const formatter = (metric && (metric.tickFormatter || metric.formatter)) || + this.props.tickFormatter || ((v) => v); + const title = metric && metric.label || ''; + const styles = reactcss({ + default: { + inner: { + top: this.state.top || 0, + left: this.state.left || 0, + transform: `matrix(${scale}, 0, 0, ${scale}, ${translateX}, ${translateY})` + } + } + }, this.props); + + const gaugeProps = { + reversed: this.props.reversed, + value, + gaugeLine: this.props.gaugeLine, + innerLine: this.props.innerLine, + innerColor: this.props.innerColor, + max: this.props.max || max, + color: metric && metric.color || '#8ac336', + type + }; + const valueStyle = {}; + if (this.props.valueColor) { + valueStyle.color = this.props.valueColor; + } + + let metrics; + if (metric) { + if (type === 'half') { + metrics = ( +
this.inner = el} + style={styles.inner}> +
{ title }
+
{ formatter(value) }
+
+ ); + } else { + metrics = ( +
+
{ formatter(value) }
+
{ title }
+
+ ); + } + } + let className = type === 'half' ? 'thorHalfGauge' : 'thorCircleGauge'; + if (this.props.reversed) className = `reversed ${className}`; + return ( +
+ this.resize = el}> + { metrics } + + +
+ ); + } + +} + +Gauge.defaultProps = { + type: 'half', + innerLine: 2, + gaugeLine: 10 +}; + +Gauge.propTypes = { + gaugeLine: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + innerColor: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + innerLine: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + max: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + metric: PropTypes.object, + reversed: PropTypes.bool, + type: PropTypes.oneOf(['half', 'circle']), + valueColor: PropTypes.string, +}; + +export default Gauge; + diff --git a/src/core_plugins/metrics/public/visualizations/components/gauge_vis.js b/src/core_plugins/metrics/public/visualizations/components/gauge_vis.js new file mode 100644 index 00000000000000..2ca2e8448a58ac --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/components/gauge_vis.js @@ -0,0 +1,193 @@ +import React, { Component, PropTypes } from 'react'; +import $ from '../lib/flot'; +import ResizeAware from 'simianhacker-react-resize-aware'; +import _ from 'lodash'; +import { findDOMNode } from 'react-dom'; +import reactcss from 'reactcss'; + +class GaugeVis extends Component { + + constructor(props) { + super(props); + this.state = { + scale: 1, + top: 0, + left: 0, + translateX: 1, + translateY: 1 + }; + this.handleResize = this.handleResize.bind(this); + } + + calculateCorrdinates() { + const inner = findDOMNode(this.inner); + const resize = findDOMNode(this.resize); + let scale = this.state.scale; + + // Let's start by scaling to the largest dimension + if (resize.clientWidth - resize.clientHeight < 0) { + scale = resize.clientWidth / inner.clientWidth; + } else { + scale = resize.clientHeight / inner.clientHeight; + } + let [ newWidth, newHeight ] = this.calcDimensions(inner, scale); + + // Now we need to check to see if it will still fit + if (newWidth > resize.clientWidth) { + scale = resize.clientWidth / inner.clientWidth; + } + if (newHeight > resize.clientHeight) { + scale = resize.clientHeight / inner.clientHeight; + } + + // Calculate the final dimensions + [ newWidth, newHeight ] = this.calcDimensions(inner, scale); + + // Because scale is middle out we need to translate + // the new X,Y corrdinates + const translateX = (newWidth - inner.clientWidth) / 2; + const translateY = (newHeight - inner.clientHeight) / 2; + + // Center up and down + const top = Math.floor((resize.clientHeight - newHeight) / 2); + const left = Math.floor((resize.clientWidth - newWidth) / 2); + + return { scale, top, left, translateY, translateX }; + } + + componentDidMount() { + const resize = findDOMNode(this.resize); + if (!resize) return; + resize.addEventListener('resize', this.handleResize); + this.handleResize(); + } + + componentWillUnmount() { + const resize = findDOMNode(this.resize); + if (!resize) return; + resize.removeEventListener('resize', this.handleResize); + } + + // When the component updates it might need to be resized so we need to + // recalculate the corrdinates and only update if things changed a little. THis + // happens when the number is too wide or you add a new series. + componentDidUpdate() { + const newState = this.calculateCorrdinates(); + if (newState && !_.isEqual(newState, this.state)) { + this.setState(newState); + } + } + + calcDimensions(el, scale) { + const newWidth = Math.floor(el.clientWidth * scale); + const newHeight = Math.floor(el.clientHeight * scale); + return [newWidth, newHeight]; + } + + handleResize() { + // Bingo! + const newState = this.calculateCorrdinates(); + newState && this.setState(newState); + } + + render() { + const { type, value, max, color, reversed } = this.props; + const { scale, translateX, translateY, top, left } = this.state; + const size = 2 * Math.PI * 50; + const sliceSize = type === 'half' ? 0.6 : 1; + const percent = value < max ? value / max : 1; + const styles = reactcss({ + default: { + resize: { + position: 'relative', + display: 'flex', + rowDirection: 'column', + flex: '1 0 auto' + }, + svg: { + position: 'absolute', + top: this.state.top, + left: this.state.left, + transform: `matrix(${scale}, 0, 0, ${scale}, ${translateX}, ${translateY})` + } + } + }, this.props); + + const props = { + circle: { + r: 50, + cx: 60, + cy: 60, + fill: 'rgba(0,0,0,0)', + stroke: color, + strokeWidth: this.props.gaugeLine, + strokeDasharray: `${(percent * sliceSize) * size} ${size}`, + transform: 'rotate(-90 60 60)', + }, + circleBackground: { + r: 50, + cx: 60, + cy: 60, + fill: 'rgba(0,0,0,0)', + stroke: this.props.reversed ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)', + strokeDasharray: `${sliceSize * size} ${size}`, + strokeWidth: this.props.innerLine + } + }; + + if (type === 'half') { + styles.svg.transform = `matrix(${scale}, 0, 0, ${scale}, ${translateX}, ${translateY})`; + props.circle.transform = 'rotate(-197.8 60 60)'; + props.circleBackground.transform = 'rotate(162 60 60)'; + } + + if (this.props.innerColor) { + props.circleBackground.stroke = this.props.innerColor; + } + + let svg; + if (type === 'half') { + svg = ( + + + + + ); + } else { + svg = ( + + + + + ); + } + return ( + this.resize = el} style={styles.resize}> +
this.inner = el}> + {svg} +
+
+ ); + } + +} + +GaugeVis.defaultProps = { + innerLine: 2, + gaugeLine: 10 +}; + +GaugeVis.propTypes = { + color: PropTypes.string, + gaugeLine: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + innerColor: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + innerLine: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + max: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + metric: PropTypes.object, + reversed: PropTypes.bool, + value: PropTypes.number, + type: PropTypes.oneOf(['half', 'circle']) +}; + +export default GaugeVis; + diff --git a/src/core_plugins/metrics/public/visualizations/components/horizontal_legend.js b/src/core_plugins/metrics/public/visualizations/components/horizontal_legend.js new file mode 100644 index 00000000000000..4b6bc4a0dedb2c --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/components/horizontal_legend.js @@ -0,0 +1,36 @@ +import React, { PropTypes } from 'react'; +import _ from 'lodash'; +import createLegendSeries from '../lib/create_legend_series'; + +function HorizontalLegend(props) { + const rows = props.series.map(createLegendSeries(props)); + const legendStyle = { }; + let legendControlClass = 'fa fa-chevron-down'; + if (!props.showLegend) { + legendStyle.display = 'none'; + legendControlClass = 'fa fa-chevron-up'; + } + return ( +
+
+ +
+
+ { rows } +
+
+ ); +} + +HorizontalLegend.propTypes = { + legendPosition: PropTypes.string, + onClick: PropTypes.func, + onToggle: PropTypes.func, + series: PropTypes.array, + showLegend: PropTypes.bool, + seriesValues: PropTypes.object, + seriesFilter: PropTypes.array, + tickFormatter: PropTypes.func +}; + +export default HorizontalLegend; diff --git a/src/core_plugins/metrics/public/visualizations/components/legend.js b/src/core_plugins/metrics/public/visualizations/components/legend.js new file mode 100644 index 00000000000000..ea1a77d43169b5 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/components/legend.js @@ -0,0 +1,23 @@ +import React, { PropTypes } from 'react'; +import VerticalLegend from './vertical_legend'; +import HorizontalLegend from './horizontal_legend'; + +function Legend(props) { + if (props.legendPosition === 'bottom') { + return (); + } + return (); +} + +Legend.propTypes = { + legendPosition: PropTypes.string, + onClick: PropTypes.func, + onToggle: PropTypes.func, + series: PropTypes.array, + showLegend: PropTypes.bool, + seriesValues: PropTypes.object, + seriesFilter: PropTypes.array, + tickFormatter: PropTypes.func +}; + +export default Legend; diff --git a/src/core_plugins/metrics/public/visualizations/components/metric.js b/src/core_plugins/metrics/public/visualizations/components/metric.js new file mode 100644 index 00000000000000..6dd62aef397c1e --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/components/metric.js @@ -0,0 +1,186 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import { findDOMNode } from 'react-dom'; +import ResizeAware from 'simianhacker-react-resize-aware'; +import getLastValue from '../lib/get_last_value'; +import reactcss from 'reactcss'; + +class Metric extends Component { + + constructor(props) { + super(props); + this.state = { + scale: 1, + left: 0, + top: 0, + translateX: 1, + translateY: 1 + }; + this.handleResize = this.handleResize.bind(this); + } + + componentDidMount() { + const resize = findDOMNode(this.resize); + if (!resize) return; + resize.addEventListener('resize', this.handleResize); + this.handleResize(); + } + + componentWillUnmount() { + const resize = findDOMNode(this.resize); + if (!resize) return; + resize.removeEventListener('resize', this.handleResize); + } + + calculateCorrdinates() { + const inner = findDOMNode(this.inner); + const resize = findDOMNode(this.resize); + let scale = this.state.scale; + + if (!resize) return; + + // Let's start by scaling to the largest dimension + if (resize.clientWidth - resize.clientHeight < 0) { + scale = resize.clientWidth / inner.clientWidth; + } else { + scale = resize.clientHeight / inner.clientHeight; + } + let [ newWidth, newHeight ] = this.calcDimensions(inner, scale); + + // Now we need to check to see if it will still fit + if (newWidth > resize.clientWidth) { + scale = resize.clientWidth / inner.clientWidth; + } + if (newHeight > resize.clientHeight) { + scale = resize.clientHeight / inner.clientHeight; + } + + // Calculate the final dimensions + [ newWidth, newHeight ] = this.calcDimensions(inner, scale); + + // Because scale is middle out we need to translate + // the new X,Y corrdinates + const translateX = (newWidth - inner.clientWidth) / 2; + const translateY = (newHeight - inner.clientHeight) / 2; + + // Center up and down + const top = Math.floor((resize.clientHeight - newHeight) / 2); + const left = Math.floor((resize.clientWidth - newWidth) / 2); + + return { scale, top, left, translateY, translateX }; + } + + // When the component updates it might need to be resized so we need to + // recalculate the corrdinates and only update if things changed a little. THis + // happens when the number is too wide or you add a new series. + componentDidUpdate() { + const newState = this.calculateCorrdinates(); + if (!_.isEqual(newState, this.state)) { + this.setState(newState); + } + } + + calcDimensions(el, scale) { + const newWidth = Math.floor(el.clientWidth * scale); + const newHeight = Math.floor(el.clientHeight * scale); + return [newWidth, newHeight]; + } + + handleResize() { + // Bingo! + const newState = this.calculateCorrdinates(); + this.setState(newState); + } + + render() { + const { metric, secondary } = this.props; + const { scale, translateX, translateY, top, left } = this.state; + const primaryFormatter = metric && (metric.tickFormatter || metric.formatter) || (n => n); + const primaryValue = primaryFormatter(getLastValue(metric && metric.data || 0)); + const styles = reactcss({ + default: { + container: {}, + inner: { + top: this.state.top || 0, + left: this.state.left || 0, + transform: `matrix(${scale}, 0, 0, ${scale}, ${translateX}, ${translateY})` + }, + primary_text: { + color: 'rgba(0,0,0,0.5)' + }, + primary_value: { + color: '#000' + }, + secondary_text: { + color: 'rgba(0,0,0,0.5)' + }, + secondary_value: { + color: '#000' + } + }, + reversed: { + primary_text: { + color: 'rgba(255,255,255,0.7)' + }, + primary_value: { + color: '#FFF' + }, + secondary_text: { + color: 'rgba(255,255,255,0.7)' + }, + secondary_value: { + color: '#FFF' + } + + } + }, this.props); + + if (this.props.backgroundColor) styles.container.backgroundColor = this.props.backgroundColor; + if (metric && metric.color) styles.primary_value.color = metric.color; + let primaryLabel; + if (metric && metric.label) { + primaryLabel = (
{ metric.label }
); + } + + let secondarySnippet; + if (secondary) { + const secondaryFormatter = secondary.formatter || (n => n); + const secondaryValue = secondaryFormatter(getLastValue(secondary.data)); + if (secondary.color) styles.secondary_value.color = secondary.color; + let secondaryLabel; + if (secondary.label) { + secondaryLabel = (
{ secondary.label }
); + } + secondarySnippet = ( +
+ { secondaryLabel } +
{ secondaryValue }
+
+ ); + } + + return ( +
+ this.resize = el} className="rhythm_metric__resize"> +
this.inner = el} className="rhythm_metric__inner" style={styles.inner}> +
+ { primaryLabel } +
{ primaryValue }
+
+ { secondarySnippet } +
+
+
+ ); + } + +} + +Metric.propTypes = { + backgroundColor: PropTypes.string, + metric: PropTypes.object, + secondary: PropTypes.object, + reversed: PropTypes.bool +}; + +export default Metric; diff --git a/src/core_plugins/metrics/public/visualizations/components/timeseries.js b/src/core_plugins/metrics/public/visualizations/components/timeseries.js new file mode 100644 index 00000000000000..151c606c268298 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/components/timeseries.js @@ -0,0 +1,164 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import getLastValue from '../lib/get_last_value'; +import TimeseriesChart from './timeseries_chart'; +import Legend from './legend'; +import eventBus from '../lib/events'; + +class Timeseries extends Component { + + constructor(props) { + super(props); + const values = this.getLastValues(props); + this.state = { + showLegend: props.legend != null ? props.legend : true, + values: values || {}, + show: _.keys(values) || [], + ignoreLegendUpdates: false, + ignoreVisabilityUpdates: false + }; + this.toggleFilter = this.toggleFilter.bind(this); + this.handleHideClick = this.handleHideClick.bind(this); + this.plothover = this.plothover.bind(this); + } + + filterLegend(id) { + if (!_.has(this.state.values, id)) return []; + const notAllShown = _.keys(this.state.values).length !== this.state.show.length; + const isCurrentlyShown = _.includes(this.state.show, id); + const show = []; + if (notAllShown && isCurrentlyShown) { + this.setState({ ignoreVisabilityUpdates: false, show: Object.keys(this.state.values) }); + } else { + show.push(id); + this.setState({ ignoreVisabilityUpdates: true, show: [id] }); + } + return show; + } + + toggleFilter(event, id) { + const show = this.filterLegend(id); + if (_.isFunction(this.props.onFilter)) { + this.props.onFilter(show); + } + eventBus.trigger('toggleFilter', id, this); + } + + getLastValues(props) { + const values = {}; + props.series.forEach((row) => { + // we need a valid identifier + if (!row.id) row.id = row.label; + values[row.id] = getLastValue(row.data); + }); + return values; + } + + updateLegend(pos, item) { + const values = {}; + if (pos) { + this.props.series.forEach((row) => { + if (row.data && _.isArray(row.data)) { + if (item && row.data[item.dataIndex] && row.data[item.dataIndex][0] === item.datapoint[0]) { + values[row.id] = row.data[item.dataIndex][1]; + } else { + let closest; + for (let i = 0; i < row.data.length; i++) { + closest = i; + if (row.data[i] && pos.x < row.data[i][0]) break; + } + if (!row.data[closest]) return values[row.id] = null; + const [ time, value ] = row.data[closest]; + values[row.id] = value != null && value || null; + } + } + }); + } else { + _.assign(values, this.getLastValues(this.props)); + } + + this.setState({ values }); + } + + componentWillReceiveProps(props) { + if (props.legend !== this.props.legend) this.setState({ showLegend: props.legend }); + if (!this.state.ignoreLegendUpdates) { + const values = this.getLastValues(props); + const currentKeys = _.keys(this.state.values); + const keys = _.keys(values); + const diff = _.difference(keys, currentKeys); + const nextState = { values: values }; + if (diff.length && !this.state.ignoreVisabilityUpdates) { + nextState.show = keys; + } + this.setState(nextState); + } + } + + plothover(event, pos, item) { + this.updateLegend(pos, item); + } + + handleHideClick() { + this.setState({ showLegend: !this.state.showLegend }); + } + + render() { + let className = 'rhythm_chart'; + if (this.props.reversed) { + className += ' reversed'; + } + const style = {}; + if (this.props.legendPosition === 'bottom') { + style.flexDirection = 'column'; + } + return ( +
+
+
+ +
+ +
+
+ ); + } + + + +} + +Timeseries.defaultProps = { + legned: true +}; + +Timeseries.propTypes = { + legend: PropTypes.bool, + legendPosition: PropTypes.string, + onFilter: PropTypes.func, + series: PropTypes.array, + annotations: PropTypes.array, + reversed: PropTypes.bool, + options: PropTypes.object, + tickFormatter: PropTypes.func +}; + +export default Timeseries; diff --git a/src/core_plugins/metrics/public/visualizations/components/timeseries_chart.js b/src/core_plugins/metrics/public/visualizations/components/timeseries_chart.js new file mode 100644 index 00000000000000..da57120d885462 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/components/timeseries_chart.js @@ -0,0 +1,239 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import moment from 'moment'; +import reactcss from 'reactcss'; +import FlotChart from './flot_chart'; +import Annotation from './annotation'; + +class TimeseriesChart extends Component { + + constructor(props) { + super(props); + this.state = { + annotations: [], + showTooltip: false, + mouseHoverTimer: false, + }; + this.handleMouseLeave = this.handleMouseLeave.bind(this); + this.handleMouseOver = this.handleMouseOver.bind(this); + this.renderAnnotations = this.renderAnnotations.bind(this); + this.handleDraw = this.handleDraw.bind(this); + } + + calculateLeftRight(item, plot) { + const el = this.container; + const offset = plot.offset(); + const canvas = plot.getCanvas(); + const point = plot.pointOffset({ x: item.datapoint[0], y: item.datapoint[1] }); + const edge = (point.left + 10) / canvas.width; + let right; + let left; + if (edge > 0.5) { + right = canvas.width - point.left; + left = null; + } else { + right = null; + left = point.left; + } + return [left, right]; + } + + handleDraw(plot) { + if (!plot || !this.props.annotations) return; + const annotations = this.props.annotations.reduce((acc, anno) => { + return acc.concat(anno.series.map(series => { + return { + series, + plot, + key: `${anno.id}-${series[0]}`, + icon: anno.icon, + color: anno.color + }; + })); + }, []); + this.setState({ annotations }); + } + + handleMouseOver(e, pos, item, plot) { + + if (typeof this.state.mouseHoverTimer === 'number') { + window.clearTimeout(this.state.mouseHoverTimer); + } + + if (item) { + const plotOffset = plot.getPlotOffset(); + const point = plot.pointOffset({ x: item.datapoint[0], y: item.datapoint[1] }); + const [left, right ] = this.calculateLeftRight(item, plot); + const top = point.top; + this.setState({ + showTooltip: true, + item, + left, + right, + top: top + 10, + bottom: plotOffset.bottom + }); + } + } + + handleMouseLeave(e, plot) { + this.state.mouseHoverTimer = window.setTimeout(() => { + this.setState({ showTooltip: false }); + }, 250); + } + + renderAnnotations(annotation) { + return ( + + ); + } + + render() { + const { item, right, top, left } = this.state; + const { series } = this.props; + let tooltip; + + const styles = reactcss({ + showTooltip: { + tooltipContainer: { + pointerEvents: 'none', + position: 'absolute', + top: top - 28, + left, + right, + zIndex: 100, + display: 'flex', + alignItems: 'center', + padding: '0 5px' + }, + tooltip: { + backgroundColor: this.props.reversed ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)', + color: this.props.reversed ? 'black' : 'white', + fontSize: '12px', + padding: '4px 8px', + borderRadius: '4px' + }, + rightCaret: { + display: right ? 'block' : 'none', + color: this.props.reversed ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)', + }, + leftCaret: { + display: left ? 'block' : 'none', + color: this.props.reversed ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)', + }, + date: { + color: this.props.reversed ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.7)', + fontSize: '12px', + lineHeight: '12px' + }, + items: { + display: 'flex', + alignItems: 'center' + }, + text: { + whiteSpace: 'nowrap', + fontSize: '12px', + lineHeight: '12px', + marginRight: 5 + }, + icon: { + marginRight: 5 + }, + value: { + fontSize: '12px', + flexShrink: 0, + lineHeight: '12px', + marginLeft: 5 + } + }, + hideTooltip: { + tooltipContainer: { display: 'none' }, + } + }, { + showTooltip: this.state.showTooltip, + hideTooltip: !this.state.showTooltip, + }); + + if (item) { + const metric = series.find(r => r.id === item.series.id); + const formatter = metric && metric.tickFormatter || this.props.tickFormatter || ((v) => v); + const value = item.datapoint[2] ? item.datapoint[1] - item.datapoint[2] : item.datapoint[1]; + const caretClassName = right ? 'fa fa-caret-right' : 'fa-caret-left'; + tooltip = ( +
+ +
+
+
+ +
+
{ item.series.label }
+
{ formatter(value) }
+
+
{ moment(item.datapoint[0]).format('lll') }
+
+ +
+ ); + + } + + const container = { + display: 'flex', + rowDirection: 'column', + flex: '1 0 auto', + position: 'relative' + }; + + + const params = { + crosshair: this.props.crosshair, + onPlotCreate: this.handlePlotCreate, + onBrush: this.props.onBrush, + onMouseLeave: this.handleMouseLeave, + onMouseOver: this.handleMouseOver, + onDraw: this.handleDraw, + options: this.props.options, + plothover: this.props.plothover, + reversed: this.props.reversed, + series: this.props.series, + annotations: this.props.annotations, + show: this.props.show, + tickFormatter: this.props.tickFormatter, + yaxes: this.props.yaxes + }; + + const annotations = this.state.annotations.map(this.renderAnnotations); + + return ( +
this.container = el} style={container}> + { tooltip } + { annotations } + +
+ ); + } + + +} + +TimeseriesChart.propTypes = { + crosshair: PropTypes.bool, + onBrush: PropTypes.func, + options: PropTypes.object, + plothover: PropTypes.func, + reversed: PropTypes.bool, + series: PropTypes.array, + annotations: PropTypes.array, + show: PropTypes.array, + tickFormatter: PropTypes.func, + yaxes: PropTypes.array, +}; + +export default TimeseriesChart; diff --git a/src/core_plugins/metrics/public/visualizations/components/top_n.js b/src/core_plugins/metrics/public/visualizations/components/top_n.js new file mode 100644 index 00000000000000..3acdeea192fb33 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/components/top_n.js @@ -0,0 +1,82 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import getLastValue from '../lib/get_last_value'; + +class TopN extends Component { + + handleClick(item) { + return (e) => { + if (this.props.onClick) { + this.props.onClick(item); + } + }; + } + + renderRow(maxValue) { + return item => { + const key = `${item.id || item.label}`; + const lastValue = getLastValue(item.data, item.data.length); + const formatter = item.tickFormatter || this.props.tickFormatter; + const value = formatter(lastValue); + const width = `${100 * (lastValue / maxValue)}%`; + const backgroundColor = item.color; + const style = {}; + if (this.props.onClick) { + style.cursor = 'pointer'; + } + return ( + + { item.label } + +
+ + { value } + + ); + }; + } + + render() { + if (!this.props.series) return (
); + const maxValue = this.props.series.reduce((max, series) => { + const lastValue = getLastValue(series.data, series.data.length); + return lastValue > max ? lastValue : max; + }, 0); + + const rows = _.sortBy(this.props.series, s => getLastValue(s.data, s.data.length)) + .reverse() + .map(this.renderRow(maxValue)); + let className = 'rhythm_top_n'; + if (this.props.reversed) { + className += ' reversed'; + } + + return ( +
+ + + { rows } + +
+
+ ); + } + +} + +TopN.defaultProps = { + tickFormatter: n => n, + onClick: i => i +}; + +TopN.propTypes = { + tickFormatter: PropTypes.func, + onClick: PropTypes.func, + series: PropTypes.array, + reversed: PropTypes.bool +}; + +export default TopN; diff --git a/src/core_plugins/metrics/public/visualizations/components/vertical_legend.js b/src/core_plugins/metrics/public/visualizations/components/vertical_legend.js new file mode 100644 index 00000000000000..67329753b27128 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/components/vertical_legend.js @@ -0,0 +1,49 @@ +import React, { PropTypes } from 'react'; +import _ from 'lodash'; +import createLegendSeries from '../lib/create_legend_series'; + +function VerticalLegend(props) { + const rows = props.series.map(createLegendSeries(props)); + const seriesStyle = {}; + const legendStyle = {}; + const controlStyle = {}; + let openClass = 'fa-chevron-left'; + let closeClass = 'fa-chevron-right'; + if (props.legendPosition === 'left') { + openClass = 'fa-chevron-right'; + closeClass = 'fa-chevron-left'; + legendStyle.order = '-1'; + controlStyle.order = '2'; + } + let legendControlClass = `fa ${closeClass}`; + legendStyle.width = 200; + if (!props.showLegend) { + legendStyle.width = 12; + seriesStyle.display = 'none'; + legendControlClass = `fa ${openClass}`; + } + return ( +
+
+ +
+
+ { rows } +
+
+ ); + +} + +VerticalLegend.propTypes = { + legendPosition: PropTypes.string, + onClick: PropTypes.func, + onToggle: PropTypes.func, + series: PropTypes.array, + showLegend: PropTypes.bool, + seriesValues: PropTypes.object, + seriesFilter: PropTypes.array, + tickFormatter: PropTypes.func +}; + +export default VerticalLegend; diff --git a/src/core_plugins/metrics/public/visualizations/index.js b/src/core_plugins/metrics/public/visualizations/index.js new file mode 100644 index 00000000000000..91e2c16e12d745 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/index.js @@ -0,0 +1,20 @@ +import getLastValue from './lib/get_last_value'; +import flot from './lib/flot'; +import events from './lib/events'; + +import Timeseries from './components/timeseries'; +import Metric from './components/metric'; +import Gauge from './components/gauge'; +import TopN from './components/top_n'; + +export default { + // visualizations + TopN, + Timeseries, + Metric, + Gauge, + // utilities + getLastValue, + flot, + events, +}; diff --git a/src/core_plugins/metrics/public/visualizations/less/includes/bar.less b/src/core_plugins/metrics/public/visualizations/less/includes/bar.less new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/core_plugins/metrics/public/visualizations/less/includes/chart.less b/src/core_plugins/metrics/public/visualizations/less/includes/chart.less new file mode 100644 index 00000000000000..c80436d9c6048f --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/less/includes/chart.less @@ -0,0 +1,179 @@ +.rhythm_chart { + position: relative; + display: flex; + flex-direction: column; + flex: 1 0 auto; +} + +.rhythm_chart__title { + color: @textColor; + margin: 0 0 10px; +} + +.rhythm_chart__content { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: flex; + flex: 1 0 auto; +} + +.rhythm_chart__visualization { + cursor: crosshair; + display: flex; + flex-direction: column; + flex: 1 0 auto; + position: relative; + & > div { + min-width: 1px; + width: 100%; + height: 100%; + } +} + +.rhythm_chart__legend-control { + padding: 6px 2px 0 0; + color: @textColor; + font-size: 12px; +} + +.rhythm_chart__legend-series { + flex-grow: 1; +} + +.rhythm_chart__legend { + display: flex; + font-size: 11px; + width: 200px; + padding: 5px 0; + overflow: auto; +} + +.rhythm_chart__legend_item { + cursor: pointer; + &.disabled { + opacity: 0.5; + } + padding: 5px; + border-bottom: 1px solid @lineColor; + &:first-child { + border-top: 1px solid @lineColor; + } + display: flex; + max-width: 170px; +} + +.rhythm_chart__legend_label { + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + span { + color: @textColor; + margin-left: 5px; + } +} + +.rhythm_chart__legend_value { + color: @valueColor; + margin-left: 3px; +} + +.rhythm_chart.reversed { + .rhythm_chart__title { color: @textColorReversed; } + .rhythm_chart__legend-control { color: @textColorReversed; } + .rhythm_chart__legend_item { + border-bottom: 1px solid @lineColorReversed; + &:first-child { border-top: 1px solid @lineColorReversed; } + } + .rhythm_chart__legend_label { span { color: @textColorReversed; } } + .rhythm_chart__legend_value { color: @valueColorReversed; } +} + +.rhythm_chart__legend-horizontal, +.rhythm_chart.reversed .rhythm_chart__legend-horizontal { + width: auto; + display: flex; + .rhythm_chart__legend-control { + padding: 5px 5px 0 0; + } + .rhythm_chart__legend-series { + display: flex; + flex-wrap: wrap; + } + .rhythm_chart__legend_label { + flex: 0 1 auto; + } + .rhythm_chart__legend_item { + max-width: inherit; + font-size: 12px; + display: flex; + margin-right: 10px; + border-bottom: none; + &:first-child { border-top: none; } + } +} + +.annotation { + position: absolute; + display: flex; + flex-direction: column; + z-index: 90; + align-items: center; +} +.annotation__line { + flex: 1 0 auto; +} +.annotation__icon { + position: relative; + flex: 0 0 auto; + width: 12px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} +.annotation__tooltip { + position: absolute; + bottom: 20px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} +.annotation__message, +.annotation__timestamp { + font-size: 12px; +} +.annotation__caret { + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid rgba(0,0,0,0.7); +} +.annotation__caret-reversed { + .annotation__caret; + border-top: none; + border-bottom: 5px solid rgba(0,0,0,0.7); +} +.annotation__tooltip-body { + border-radius: 4px; + padding: 4px; + white-space: nowrap; + color: white; + background-color: rgba(0,0,0,0.7); +} +.annotation__tooltip-body-reversed { + .annotation__tooltip-body; + color: black; + background-color: rgba(255,255,255,0.7); +} +.annotation__timestamp { + color: rgba(255,255,255,0.7); + +} +.annotation__timestamp-reversed { + color: rgba(0,0,0,0.7); +} diff --git a/src/core_plugins/metrics/public/visualizations/less/includes/colors.less b/src/core_plugins/metrics/public/visualizations/less/includes/colors.less new file mode 100644 index 00000000000000..ebd1b4bf547a40 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/less/includes/colors.less @@ -0,0 +1,32 @@ +@black: black; +@grayDarkest: #222; +@grayDarker: #333; +@grayDark: #666; +@gray: #999; +@grayLight: #CCC; +@grayLighter: #DDD; +@grayLightest: #EEE; +@white: white; + +@background: @white; +@navBarBackground: @grayLighter; +@inputBorder: @grayLighter; +@lineColor: rgba(0,0,0,0.2); +@lineColorReversed: rgba(255,255,255,0.4); +@textColor: rgba(0,0,0,0.4); +@textColorReversed: rgba(255,255,255,0.6); +@disabledColor: @grayLight; +@valueColor: rgba(0,0,0,0.7); +@valueColorReversed: rgba(255,255,255,0.8); + +@esBlue: #6eadc1; +@esRed: #d76051; +@esYellow: #fbce47; +@esGreen: #80c383; +@esPink: #e8488b; +@esPurple: #9980b2; +@esAltGreen: #8ac336; +@esCyan: #59c6c5; + + + diff --git a/src/core_plugins/metrics/public/visualizations/less/includes/gauge.less b/src/core_plugins/metrics/public/visualizations/less/includes/gauge.less new file mode 100644 index 00000000000000..7b714d3860163a --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/less/includes/gauge.less @@ -0,0 +1,113 @@ +.thorCircleGauge { + font-size: 100%; + display: flex; + flex-direction: column; + flex: 1 0 auto; + circle { + opacity: 1; + stroke-opacity: 1; + &:hover { + opacity: 1; + stroke-opacity: 1; + } + } +} + +.thorCircleGauge__metrics { + position: absolute; + width: 100px; + height: 100px; + text-align: center; + display: flex; + padding: 10px; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.thorCircleGauge__label { + font-size: 8px; + line-height: 1; + text-align: center; + color: rgba(0,0,0,0.4); + padding: 0 8px 4px; +} +.thorCircleGauge__value { + font-size: 12px; + line-height: 1; + text-align: center; + color: @black; +} + +.thorCircleGauge__resize { + position: relative; + display: flex; + flex-direction: column; + flex: 1 0 auto; +} + +.thorCircleGauge.reversed { + .thorCircleGauge__label { + color: rgba(255,255,255,0.6); + } + .thorCircleGauge__value { + color: @white; + } +} + +.thorHalfGauge { + font-size: 100%; + display: flex; + flex-direction: column; + flex: 1 0 auto; + circle { + opacity: 1; + stroke-opacity: 1; + &:hover { + opacity: 1; + stroke-opacity: 1; + } + } +} + +.thorHalfGauge__metrics { + position: absolute; + width: 100px; + height: 70px; + text-align: center; + display: flex; + padding: 0px 11px 10px; + flex-direction: column; + align-items: center; + justify-content: flex-end; +} + +.thorHalfGauge__label { + font-size: 8px; + line-height: 1; + text-align: center; + color: rgba(0,0,0,0.4); + padding: 0 8px 4px; +} +.thorHalfGauge__value { + font-size: 13px; + line-height: 1; + text-align: center; + color: @black; +} + +.thorHalfGauge__resize { + position: relative; + display: flex; + rowDirection: column; + flex: 1 0 auto; +} + +.thorHalfGauge.reversed { + .thorHalfGauge__label { + color: rgba(255,255,255,0.6); + } + .thorHalfGauge__value { + color: @white; + } +} diff --git a/src/core_plugins/metrics/public/visualizations/less/includes/metric.less b/src/core_plugins/metrics/public/visualizations/less/includes/metric.less new file mode 100644 index 00000000000000..7fce968416bdde --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/less/includes/metric.less @@ -0,0 +1,61 @@ +.rhythm_metric { + position: relative; + display: flex; + flex-direction: column; + flex: 1 0 auto; +} + +.rhythm_metric__resize { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + overflow: hidden; + display: flex; +} + +.rhythm_metric__primary { +} + +.rhythm_metric__primary-label { + text-align: center; + color: @textColor; + font-size: 0.5em; + margin-bottom: 0.25em; + line-height: 1em; +} + +.rhythm_metric__primary-value { + text-align: center; + color: @valueColor; + font-size: 1em; + font-weight: bold; + line-height: 1em; +} + +.rhythm_metric__secondary { + display: flex; + justify-content: center; + align-items: center; + margin-top: 0.05em; +} + +.rhythm_metric__secondary-label { + font-size: 0.35em; + margin-right: 0.3em; + color: @textColor; + line-height: 1em; +} + +.rhythm_metric__secondary-value { + font-size: 0.35em; + color: @valueColor; + line-height: 1em; +} + +.rhythm_metric__inner { + position: absolute; + padding: 0.25em 0.5em 0.5em; +} + diff --git a/src/core_plugins/metrics/public/visualizations/less/includes/top_n.less b/src/core_plugins/metrics/public/visualizations/less/includes/top_n.less new file mode 100644 index 00000000000000..c206c85439217b --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/less/includes/top_n.less @@ -0,0 +1,55 @@ +.rhythm_top_n { + position: relative; + overflow: auto; + display: flex; + flex-direction: column; + + tr:hover { td { background-color: rgba(0,0,0,0.1) } } +} + +.rhythm_top_n__labels, +.rhythm_top_n__values { + // flex: 0 0 auto; + text-align: right; +} +.rhythm_top_n__bars { + // flex: 1 0 auto; +} + +.rhythm_top_n__label { + // margin: 4px 10px 4px 0; + color: @textColor; + white-space: nowrap; + text-align: right; + line-height: 0; + padding: 4px 0; + vertical-align: middle; + // height: 18px; +} + +.rhythm_top_n__value { + // margin: 4px 0 4px 10px; + color: @valueColor; + text-align: right; + line-height: 0; + padding: 4px 4px 4px 0; + vertical-align: middle; + // height: 18px; +} + +.rhythm_top_n__bar { + // height: 16px; + // margin: 4px 0; + padding: 4px 10px; + vertical-align: middle; +} + +.rhythm_top_n__inner-bar { + min-height: 16px; +} + +.rhythm_top_n.reversed { + tr:hover { td { background-color: rgba(255,255,255,0.1) } } + .rhythm_top_n__label { color: @textColorReversed; } + .rhythm_top_n__value { color: @valueColorReversed; } +} diff --git a/src/core_plugins/metrics/public/visualizations/less/main.less b/src/core_plugins/metrics/public/visualizations/less/main.less new file mode 100644 index 00000000000000..b04b21908d8fc3 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/less/main.less @@ -0,0 +1,5 @@ +@import './includes/colors.less'; +@import './includes/chart.less'; +@import './includes/metric.less'; +@import './includes/top_n.less'; +@import './includes/gauge.less'; diff --git a/src/core_plugins/metrics/public/visualizations/lib/__tests__/calcualte_bar_width.js b/src/core_plugins/metrics/public/visualizations/lib/__tests__/calcualte_bar_width.js new file mode 100644 index 00000000000000..91762bba2b9fe2 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/lib/__tests__/calcualte_bar_width.js @@ -0,0 +1,18 @@ +import { expect } from 'chai'; +import calculateBarWidth from '../calculate_bar_width'; + +describe('calculateBarWidth(series, divisor, multipier)', () => { + + it('returns default bar width', () => { + const series = [{ data: [[100, 100], [200, 100]] }]; + expect(calculateBarWidth(series)).to.equal(70); + }); + + it('returns custom bar width', () => { + const series = [{ data: [[100, 100], [200, 100]] }]; + expect(calculateBarWidth(series, 2)).to.equal(200); + }); + +}); + + diff --git a/src/core_plugins/metrics/public/visualizations/lib/__tests__/get_last_value.js b/src/core_plugins/metrics/public/visualizations/lib/__tests__/get_last_value.js new file mode 100644 index 00000000000000..1c70f45f62b2ef --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/lib/__tests__/get_last_value.js @@ -0,0 +1,32 @@ +import { expect } from 'chai'; +import getLastValue from '../get_last_value'; + +describe('getLastValue(data)', () => { + + it('returns zero if data is not array', () => { + expect(getLastValue('foo')).to.equal(0); + }); + + it('returns the last value', () => { + const data = [[1,1]]; + expect(getLastValue(data)).to.equal(1); + }); + + it('returns the second to last value if the last value is null (default)', () => { + const data = [[1,4], [2, null]]; + expect(getLastValue(data)).to.equal(4); + }); + + it('returns the zero if second to last is null (default)', () => { + const data = [[1, null], [2, null]]; + expect(getLastValue(data)).to.equal(0); + }); + + it('returns the N to last value if the last N-1 values are null (default)', () => { + const data = [[1,4], [2, null], [3, null]]; + expect(getLastValue(data, 3)).to.equal(4); + }); + + +}); + diff --git a/src/core_plugins/metrics/public/visualizations/lib/__tests__/get_value_by.js b/src/core_plugins/metrics/public/visualizations/lib/__tests__/get_value_by.js new file mode 100644 index 00000000000000..ac43a53ea3a98d --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/lib/__tests__/get_value_by.js @@ -0,0 +1,23 @@ +import getValueBy from '../get_value_by'; +import { expect } from 'chai'; + +describe('getValueBy(fn, data)', () => { + it('returns max for getValueBy(\'max\', data) ', () => { + const data = [ + [0, 5], + [1, 3], + [2, 4], + [3, 6], + [4, 5], + ]; + expect(getValueBy('max', data)).to.equal(6); + }); + it('returns 0 if data is not array', () => { + const data = '1'; + expect(getValueBy('max', data)).to.equal(0); + }); + it('returns value if data is number', () => { + const data = 1; + expect(getValueBy('max', data)).to.equal(1); + }); +}); diff --git a/src/core_plugins/metrics/public/visualizations/lib/calculate_bar_width.js b/src/core_plugins/metrics/public/visualizations/lib/calculate_bar_width.js new file mode 100644 index 00000000000000..0290049d6e342c --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/lib/calculate_bar_width.js @@ -0,0 +1,12 @@ +import _ from 'lodash'; +// bar sizes are measured in milliseconds so this assumes that the different +// between timestamps is in milliseconds. A normal bar size is 70% which gives +// enough spacing for the bar. +export default (series, multipier = 0.7) => { + const first = _.first(series); + try { + return ((first.data[1][0] - first.data[0][0])) * multipier; + } catch (e) { + return 1000; // 1000 ms + } +}; diff --git a/src/core_plugins/metrics/public/visualizations/lib/colors.js b/src/core_plugins/metrics/public/visualizations/lib/colors.js new file mode 100644 index 00000000000000..028af4b7e85a56 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/lib/colors.js @@ -0,0 +1,8 @@ +export default { + lineColor: 'rgba(0,0,0,0.2)', + lineColorReversed: 'rgba(255,255,255,0.4)', + textColor: 'rgba(0,0,0,0.4)', + textColorReversed: 'rgba(255,255,255,0.6)', + valueColor: 'rgba(0,0,0,0.7)', + valueColorReversed: 'rgba(255,255,255,0.8)' +}; diff --git a/src/core_plugins/metrics/public/visualizations/lib/create_legend_series.js b/src/core_plugins/metrics/public/visualizations/lib/create_legend_series.js new file mode 100644 index 00000000000000..84c58340578f7e --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/lib/create_legend_series.js @@ -0,0 +1,28 @@ +import React from 'react'; +import _ from 'lodash'; +export default props => (row, i) => { + + function tickFormatter(value) { + if (_.isFunction(props.tickFormatter)) return props.tickFormatter(value); + return value; + } + + const formatter = row.tickFormatter || tickFormatter; + const value = formatter(props.seriesValues[row.id]); + const classes = ['rhythm_chart__legend_item']; + const key = row.id; + if (!_.includes(props.seriesFilter, row.id)) classes.push('disabled'); + if (!row.label || row.legend === false) return (
); + return ( +
props.onToggle(event, row.id) } + key={ key }> +
+ + { row.label } +
+
{ value }
+
+ ); +}; diff --git a/src/core_plugins/metrics/public/visualizations/lib/events.js b/src/core_plugins/metrics/public/visualizations/lib/events.js new file mode 100644 index 00000000000000..35c193bcccf617 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/lib/events.js @@ -0,0 +1,3 @@ +import $ from 'jquery'; +export default $({}); + diff --git a/src/core_plugins/metrics/public/visualizations/lib/flot.js b/src/core_plugins/metrics/public/visualizations/lib/flot.js new file mode 100644 index 00000000000000..2cdef0fbdef03c --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/lib/flot.js @@ -0,0 +1,13 @@ +const $ = require('jquery'); +if (window) window.jQuery = $; +require('flot-charts/jquery.flot'); +require('flot-charts/jquery.flot.time'); +require('flot-charts/jquery.flot.canvas'); +require('flot-charts/jquery.flot.symbol'); +require('flot-charts/jquery.flot.crosshair'); +require('flot-charts/jquery.flot.selection'); +require('flot-charts/jquery.flot.pie'); +require('flot-charts/jquery.flot.stack'); +require('flot-charts/jquery.flot.threshold'); +require('flot-charts/jquery.flot.fillbetween'); +module.exports = $; diff --git a/src/core_plugins/metrics/public/visualizations/lib/get_last_value.js b/src/core_plugins/metrics/public/visualizations/lib/get_last_value.js new file mode 100644 index 00000000000000..0003fef705ba7f --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/lib/get_last_value.js @@ -0,0 +1,20 @@ +import _ from 'lodash'; +export default (data, lookback = 2) => { + if (_.isNumber(data)) return data; + if (!Array.isArray(data)) return 0; + // First try the last value + const last = data[data.length - 1]; + const lastValue = Array.isArray(last) && last[1]; + if (lastValue) return lastValue; + + // If the last value is zero or null because of a partial bucket or + // some kind of timeshift weirdness we will show the second to last. + let lookbackCounter = 1; + let value; + while (lookback > lookbackCounter && !value) { + const next = data[data.length - ++lookbackCounter]; + value = _.isArray(next) && next[1] || 0; + } + return value || 0; +}; + diff --git a/src/core_plugins/metrics/public/visualizations/lib/get_value_by.js b/src/core_plugins/metrics/public/visualizations/lib/get_value_by.js new file mode 100644 index 00000000000000..a8a1de0ee668f0 --- /dev/null +++ b/src/core_plugins/metrics/public/visualizations/lib/get_value_by.js @@ -0,0 +1,7 @@ +import _ from 'lodash'; +export default (fn, data) => { + if (_.isNumber(data)) return data; + if (!Array.isArray(data)) return 0; + const values = data.map(v => v[1]); + return _[fn](values); +}; diff --git a/src/core_plugins/metrics/server/lib/__tests__/get_fields.js b/src/core_plugins/metrics/server/lib/__tests__/get_fields.js new file mode 100644 index 00000000000000..1877764f83b9ea --- /dev/null +++ b/src/core_plugins/metrics/server/lib/__tests__/get_fields.js @@ -0,0 +1,84 @@ +import { expect } from 'chai'; +import { getParams, handleResponse } from '../get_fields'; + +describe('getFields', () => { + + describe('getParams', () => { + + it('returns a valid params object', () => { + const req = { query: { index: 'metricbeat-*' } }; + expect(getParams(req)).to.eql({ + index: 'metricbeat-*', + fields: ['*'], + ignoreUnavailable: false, + allowNoIndices: false, + includeDefaults: true + }); + }); + + }); + + describe('handleResponse', () => { + it('returns a valid response', () => { + const resp = { + 'foo': { + 'mappings': { + 'bar': { + '@timestamp': { + 'full_name': '@timestamp', + 'mapping': { + '@timestamp': { + 'type': 'date' + } + } + } + } + } + }, + 'twitter': { + 'mappings': { + 'tweet': { + 'message': { + 'full_name': 'message', + 'mapping': { + 'message': { + 'type': 'text', + 'fields': { + 'keyword': { + 'type': 'keyword', + 'ignore_above': 256 + } + } + } + } + }, + '@timestamp': { + 'full_name': '@timestamp', + 'mapping': { + '@timestamp': { + 'type': 'date' + } + } + }, + 'id.keyword': { + 'full_name': 'id.keyword', + 'mapping': { + 'keyword': { + 'type': 'keyword', + 'ignore_above': 256 + } + } + } + } + } + } + }; + expect(handleResponse(resp)).to.eql([ + { name: '@timestamp', type: 'date' }, + { name: 'id.keyword', type: 'keyword' }, + { name: 'message', type: 'text' } + ]); + }); + }); + +}); diff --git a/src/core_plugins/metrics/server/lib/get_fields.js b/src/core_plugins/metrics/server/lib/get_fields.js new file mode 100644 index 00000000000000..8ffa3f1bd09403 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/get_fields.js @@ -0,0 +1,40 @@ +import _ from 'lodash'; + +export function getParams(req) { + const index = req.query.index || '*'; + return { + index, + fields: ['*'], + ignoreUnavailable: false, + allowNoIndices: false, + includeDefaults: true + }; +} + +export function handleResponse(resp) { + return _.reduce(resp, (acc, index, key) => { + _.each(index.mappings, (type) => { + _.each(type, (field, fullName) => { + const name = _.last(fullName.split(/\./)); + const enabled = _.get(field, `mapping.${name}.enabled`, true); + const fieldType = _.get(field, `mapping.${name}.type`); + if (enabled && fieldType) { + acc.push({ + name: _.get(field, 'full_name', fullName), + type: fieldType + }); + } + }); + }); + return _(acc).sortBy('name').uniq(row => row.name).value(); + }, []); +} + +function getFields(req) { + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('data'); + const params = getParams(req); + return callWithRequest(req, 'indices.getFieldMapping', params).then(handleResponse); +} + +export default getFields; + diff --git a/src/core_plugins/metrics/server/lib/get_vis_data.js b/src/core_plugins/metrics/server/lib/get_vis_data.js new file mode 100644 index 00000000000000..5f65e468a9c298 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/get_vis_data.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import getPanelData from './vis_data/get_panel_data'; + +function getVisData(req) { + const promises = req.payload.panels.map(getPanelData(req)); + return Promise.all(promises) + .then(res => { + return res.reduce((acc, data) => { + return _.assign(acc, data); + }, {}); + }); +} + +export default getVisData; + diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/build_processor_function.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/build_processor_function.js new file mode 100644 index 00000000000000..c6d9cfa3c68252 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/build_processor_function.js @@ -0,0 +1,47 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import buildProcessorFunction from '../build_processor_function'; + +describe('buildProcessorFunction(chain, ...args)', () => { + const req = {}; + const panel = {}; + const series = {}; + + it('should call each processor', () => { + const first = sinon.spy((req, panel, series) => next => doc => next(doc)); + const second = sinon.spy((req, panel, series) => next => doc => next(doc)); + const fn = buildProcessorFunction([first, second], req, panel, series); + expect(first.calledOnce).to.equal(true); + expect(second.calledOnce).to.equal(true); + }); + + it('should chain each processor', () => { + const first = sinon.spy(next => doc => next(doc)); + const second = sinon.spy(next => doc => next(doc)); + const fn = buildProcessorFunction([ + (req, panel, series) => first, + (req, panel, series) => second + ], req, panel, series); + expect(first.calledOnce).to.equal(true); + expect(second.calledOnce).to.equal(true); + }); + + it('should next of each processor', () => { + const first = sinon.spy(); + const second = sinon.spy(); + const fn = buildProcessorFunction([ + (req, panel, series) => next => doc => { + first(); + next(doc); + }, + (req, panel, series) => next => doc => { + second(); + next(doc); + } + ], req, panel, series); + fn({}); + expect(first.calledOnce).to.equal(true); + expect(second.calledOnce).to.equal(true); + }); + +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/build_request_body.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/build_request_body.js new file mode 100644 index 00000000000000..fa6cdb81477c2f --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/build_request_body.js @@ -0,0 +1,133 @@ +const body = JSON.parse(` +{ + "filters": [ + { + "bool": { + "must": [ + { + "query_string": { + "analyze_wildcard": true, + "query": "*" + } + } + ], + "must_not": [] + } + } + ], + "panels": [ + { + "axis_formatter": "number", + "axis_position": "left", + "id": "c9b5d2b0-e403-11e6-be91-6f7688e9fac7", + "index_pattern": "*", + "interval": "auto", + "series": [ + { + "axis_position": "right", + "chart_type": "line", + "color": "rgba(250,40,255,1)", + "fill": 0, + "formatter": "number", + "id": "c9b5f9c0-e403-11e6-be91-6f7688e9fac7", + "line_width": 1, + "metrics": [ + { + "id": "c9b5f9c1-e403-11e6-be91-6f7688e9fac7", + "type": "count" + } + ], + "point_size": 1, + "seperate_axis": 0, + "split_mode": "everything", + "stacked": 0 + } + ], + "show_legend": 1, + "time_field": "@timestamp", + "type": "timeseries" + } + ], + "timerange": { + "max": "2017-01-26T20:52:35.881Z", + "min": "2017-01-26T20:37:35.881Z" + } +} +`); + +import buildRequestBody from '../build_request_body'; +import { expect } from 'chai'; + +describe('buildRequestBody(req)', () => { + it('returns a valid body', () => { + const panel = body.panels[0]; + const series = panel.series[0]; + const doc = buildRequestBody({ payload: body }, panel, series); + expect(doc).to.eql({ + 'size': 0, + 'query': { + 'bool': { + 'must': [ + { + 'range': { + '@timestamp': { + 'gte': 1485463055881, + 'lte': 1485463945881, + 'format': 'epoch_millis' + } + } + }, + { + 'bool': { + 'must': [ + { + 'query_string': { + 'analyze_wildcard': true, + 'query': '*' + } + } + ], + 'must_not': [] + } + } + ] + } + }, + 'aggs': { + 'c9b5f9c0-e403-11e6-be91-6f7688e9fac7': { + 'filter': { + 'match_all': {} + }, + 'aggs': { + 'timeseries': { + 'date_histogram': { + 'field': '@timestamp', + 'interval': '10s', + 'min_doc_count': 0, + 'extended_bounds': { + 'min': 1485463055881, + 'max': 1485463945881 + } + }, + 'aggs': { + 'c9b5f9c1-e403-11e6-be91-6f7688e9fac7': { + 'bucket_script': { + 'buckets_path': { + 'count': '_count' + }, + 'script': { + 'inline': 'count * 1', + 'lang': 'expression' + }, + 'gap_policy': 'skip' + } + } + } + } + } + } + } + }); + }); +}); + diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/calculate_indices.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/calculate_indices.js new file mode 100644 index 00000000000000..4b486c17cb7064 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/calculate_indices.js @@ -0,0 +1,56 @@ +import { expect } from 'chai'; +import { getParams, handleResponse } from '../calculate_indices'; + +describe('calculateIndices', () => { + + describe('getParams', () => { + const req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00.000Z', + max: '2017-01-03T23:59:59.000Z' + }, + } + }; + + it('should return a valid param object', () => { + expect(getParams(req, 'metricbeat-*', '@timestamp')).to.eql({ + index: 'metricbeat-*', + level: 'indices', + ignoreUnavailable: true, + body: { + fields: ['@timestamp'], + index_constraints: { + '@timestamp': { + max_value: { gte: '2017-01-01T00:00:00.000Z' }, + min_value: { lte: '2017-01-03T23:59:59.000Z' } + } + } + } + }); + }); + + }); + + describe('handleResponse', () => { + it('returns an array of indices', () => { + const resp = { + indices: { + 'metricbeat-2017.01.01': {}, + 'metricbeat-2017.01.02': {}, + 'metricbeat-2017.01.03': {} + } + }; + expect(handleResponse(resp)).to.eql([ + 'metricbeat-2017.01.01', + 'metricbeat-2017.01.02', + 'metricbeat-2017.01.03', + ]); + }); + + it('returns an empty array if none found', () => { + const resp = { indices: { } }; + expect(handleResponse(resp)).to.have.length(0); + }); + }); +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/fixture.json b/src/core_plugins/metrics/server/lib/vis_data/__tests__/fixture.json new file mode 100644 index 00000000000000..178704a36372dd --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/fixture.json @@ -0,0 +1,984 @@ +{ + "_shards": { + "failed": 0, + "successful": 5, + "total": 5 + }, + "aggregations": { + "c9b5f9c0-e403-11e6-be91-6f7688e9fac7": { + "doc_count": 128145, + "timeseries": { + "buckets": [ + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.057 + }, + "doc_count": 368, + "key": 1485549090000, + "key_as_string": "2017-01-27T20:31:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.07466666666666667 + }, + "doc_count": 1106, + "key": 1485549120000, + "key_as_string": "2017-01-27T20:32:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.08033333333333335 + }, + "doc_count": 1107, + "key": 1485549150000, + "key_as_string": "2017-01-27T20:32:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.066 + }, + "doc_count": 1109, + "key": 1485549180000, + "key_as_string": "2017-01-27T20:33:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.05366666666666667 + }, + "doc_count": 1093, + "key": 1485549210000, + "key_as_string": "2017-01-27T20:33:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04533333333333334 + }, + "doc_count": 1086, + "key": 1485549240000, + "key_as_string": "2017-01-27T20:34:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037333333333333336 + }, + "doc_count": 1086, + "key": 1485549270000, + "key_as_string": "2017-01-27T20:34:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1090, + "key": 1485549300000, + "key_as_string": "2017-01-27T20:35:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.036000000000000004 + }, + "doc_count": 1085, + "key": 1485549330000, + "key_as_string": "2017-01-27T20:35:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1082, + "key": 1485549360000, + "key_as_string": "2017-01-27T20:36:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1080, + "key": 1485549390000, + "key_as_string": "2017-01-27T20:36:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037000000000000005 + }, + "doc_count": 1082, + "key": 1485549420000, + "key_as_string": "2017-01-27T20:37:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.036000000000000004 + }, + "doc_count": 1079, + "key": 1485549450000, + "key_as_string": "2017-01-27T20:37:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03233333333333333 + }, + "doc_count": 1080, + "key": 1485549480000, + "key_as_string": "2017-01-27T20:38:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1080, + "key": 1485549510000, + "key_as_string": "2017-01-27T20:38:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1082, + "key": 1485549540000, + "key_as_string": "2017-01-27T20:39:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.042666666666666665 + }, + "doc_count": 1079, + "key": 1485549570000, + "key_as_string": "2017-01-27T20:39:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1077, + "key": 1485549600000, + "key_as_string": "2017-01-27T20:40:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03833333333333334 + }, + "doc_count": 1075, + "key": 1485549630000, + "key_as_string": "2017-01-27T20:40:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1076, + "key": 1485549660000, + "key_as_string": "2017-01-27T20:41:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1076, + "key": 1485549690000, + "key_as_string": "2017-01-27T20:41:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1074, + "key": 1485549720000, + "key_as_string": "2017-01-27T20:42:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1072, + "key": 1485549750000, + "key_as_string": "2017-01-27T20:42:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1067, + "key": 1485549780000, + "key_as_string": "2017-01-27T20:43:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.036000000000000004 + }, + "doc_count": 1065, + "key": 1485549810000, + "key_as_string": "2017-01-27T20:43:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1065, + "key": 1485549840000, + "key_as_string": "2017-01-27T20:44:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.036333333333333336 + }, + "doc_count": 1062, + "key": 1485549870000, + "key_as_string": "2017-01-27T20:44:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1063, + "key": 1485549900000, + "key_as_string": "2017-01-27T20:45:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1065, + "key": 1485549930000, + "key_as_string": "2017-01-27T20:45:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1065, + "key": 1485549960000, + "key_as_string": "2017-01-27T20:46:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1069, + "key": 1485549990000, + "key_as_string": "2017-01-27T20:46:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03333333333333333 + }, + "doc_count": 1068, + "key": 1485550020000, + "key_as_string": "2017-01-27T20:47:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1068, + "key": 1485550050000, + "key_as_string": "2017-01-27T20:47:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1068, + "key": 1485550080000, + "key_as_string": "2017-01-27T20:48:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1074, + "key": 1485550110000, + "key_as_string": "2017-01-27T20:48:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03133333333333333 + }, + "doc_count": 1074, + "key": 1485550140000, + "key_as_string": "2017-01-27T20:49:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1074, + "key": 1485550170000, + "key_as_string": "2017-01-27T20:49:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03133333333333333 + }, + "doc_count": 1073, + "key": 1485550200000, + "key_as_string": "2017-01-27T20:50:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1077, + "key": 1485550230000, + "key_as_string": "2017-01-27T20:50:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03166666666666667 + }, + "doc_count": 1074, + "key": 1485550260000, + "key_as_string": "2017-01-27T20:51:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.031 + }, + "doc_count": 1074, + "key": 1485550290000, + "key_as_string": "2017-01-27T20:51:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1072, + "key": 1485550320000, + "key_as_string": "2017-01-27T20:52:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1073, + "key": 1485550350000, + "key_as_string": "2017-01-27T20:52:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03466666666666667 + }, + "doc_count": 1071, + "key": 1485550380000, + "key_as_string": "2017-01-27T20:53:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1071, + "key": 1485550410000, + "key_as_string": "2017-01-27T20:53:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03166666666666667 + }, + "doc_count": 1069, + "key": 1485550440000, + "key_as_string": "2017-01-27T20:54:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1069, + "key": 1485550470000, + "key_as_string": "2017-01-27T20:54:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.032 + }, + "doc_count": 1068, + "key": 1485550500000, + "key_as_string": "2017-01-27T20:55:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1067, + "key": 1485550530000, + "key_as_string": "2017-01-27T20:55:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03233333333333333 + }, + "doc_count": 1065, + "key": 1485550560000, + "key_as_string": "2017-01-27T20:56:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1069, + "key": 1485550590000, + "key_as_string": "2017-01-27T20:56:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03166666666666667 + }, + "doc_count": 1068, + "key": 1485550620000, + "key_as_string": "2017-01-27T20:57:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03333333333333333 + }, + "doc_count": 1068, + "key": 1485550650000, + "key_as_string": "2017-01-27T20:57:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1068, + "key": 1485550680000, + "key_as_string": "2017-01-27T20:58:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1071, + "key": 1485550710000, + "key_as_string": "2017-01-27T20:58:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03333333333333333 + }, + "doc_count": 1074, + "key": 1485550740000, + "key_as_string": "2017-01-27T20:59:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1074, + "key": 1485550770000, + "key_as_string": "2017-01-27T20:59:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04 + }, + "doc_count": 1074, + "key": 1485550800000, + "key_as_string": "2017-01-27T21:00:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.032 + }, + "doc_count": 1076, + "key": 1485550830000, + "key_as_string": "2017-01-27T21:00:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1078, + "key": 1485550860000, + "key_as_string": "2017-01-27T21:01:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1077, + "key": 1485550890000, + "key_as_string": "2017-01-27T21:01:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03466666666666667 + }, + "doc_count": 1071, + "key": 1485550920000, + "key_as_string": "2017-01-27T21:02:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03466666666666667 + }, + "doc_count": 1071, + "key": 1485550950000, + "key_as_string": "2017-01-27T21:02:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03566666666666667 + }, + "doc_count": 1073, + "key": 1485550980000, + "key_as_string": "2017-01-27T21:03:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1071, + "key": 1485551010000, + "key_as_string": "2017-01-27T21:03:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03466666666666667 + }, + "doc_count": 1069, + "key": 1485551040000, + "key_as_string": "2017-01-27T21:04:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03566666666666667 + }, + "doc_count": 1068, + "key": 1485551070000, + "key_as_string": "2017-01-27T21:04:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1075, + "key": 1485551100000, + "key_as_string": "2017-01-27T21:05:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1074, + "key": 1485551130000, + "key_as_string": "2017-01-27T21:05:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03133333333333333 + }, + "doc_count": 1073, + "key": 1485551160000, + "key_as_string": "2017-01-27T21:06:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1071, + "key": 1485551190000, + "key_as_string": "2017-01-27T21:06:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03333333333333333 + }, + "doc_count": 1075, + "key": 1485551220000, + "key_as_string": "2017-01-27T21:07:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03133333333333333 + }, + "doc_count": 1071, + "key": 1485551250000, + "key_as_string": "2017-01-27T21:07:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04733333333333334 + }, + "doc_count": 1081, + "key": 1485551280000, + "key_as_string": "2017-01-27T21:08:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.044333333333333336 + }, + "doc_count": 1078, + "key": 1485551310000, + "key_as_string": "2017-01-27T21:08:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037000000000000005 + }, + "doc_count": 1079, + "key": 1485551340000, + "key_as_string": "2017-01-27T21:09:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1077, + "key": 1485551370000, + "key_as_string": "2017-01-27T21:09:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03866666666666666 + }, + "doc_count": 1077, + "key": 1485551400000, + "key_as_string": "2017-01-27T21:10:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037666666666666675 + }, + "doc_count": 1075, + "key": 1485551430000, + "key_as_string": "2017-01-27T21:10:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.038000000000000006 + }, + "doc_count": 1078, + "key": 1485551460000, + "key_as_string": "2017-01-27T21:11:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037 + }, + "doc_count": 1074, + "key": 1485551490000, + "key_as_string": "2017-01-27T21:11:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.036666666666666674 + }, + "doc_count": 1074, + "key": 1485551520000, + "key_as_string": "2017-01-27T21:12:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037333333333333336 + }, + "doc_count": 1076, + "key": 1485551550000, + "key_as_string": "2017-01-27T21:12:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03733333333333333 + }, + "doc_count": 1075, + "key": 1485551580000, + "key_as_string": "2017-01-27T21:13:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04533333333333334 + }, + "doc_count": 1077, + "key": 1485551610000, + "key_as_string": "2017-01-27T21:13:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.039 + }, + "doc_count": 1080, + "key": 1485551640000, + "key_as_string": "2017-01-27T21:14:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.057666666666666665 + }, + "doc_count": 1080, + "key": 1485551670000, + "key_as_string": "2017-01-27T21:14:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.045000000000000005 + }, + "doc_count": 1080, + "key": 1485551700000, + "key_as_string": "2017-01-27T21:15:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037666666666666675 + }, + "doc_count": 1080, + "key": 1485551730000, + "key_as_string": "2017-01-27T21:15:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1080, + "key": 1485551760000, + "key_as_string": "2017-01-27T21:16:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.038 + }, + "doc_count": 1080, + "key": 1485551790000, + "key_as_string": "2017-01-27T21:16:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1080, + "key": 1485551820000, + "key_as_string": "2017-01-27T21:17:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04966666666666667 + }, + "doc_count": 1080, + "key": 1485551850000, + "key_as_string": "2017-01-27T21:17:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1080, + "key": 1485551880000, + "key_as_string": "2017-01-27T21:18:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04 + }, + "doc_count": 1080, + "key": 1485551910000, + "key_as_string": "2017-01-27T21:18:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03766666666666666 + }, + "doc_count": 1080, + "key": 1485551940000, + "key_as_string": "2017-01-27T21:19:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1076, + "key": 1485551970000, + "key_as_string": "2017-01-27T21:19:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1077, + "key": 1485552000000, + "key_as_string": "2017-01-27T21:20:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1077, + "key": 1485552030000, + "key_as_string": "2017-01-27T21:20:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.029666666666666664 + }, + "doc_count": 1077, + "key": 1485552060000, + "key_as_string": "2017-01-27T21:21:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.02766666666666667 + }, + "doc_count": 1077, + "key": 1485552090000, + "key_as_string": "2017-01-27T21:21:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.028 + }, + "doc_count": 1077, + "key": 1485552120000, + "key_as_string": "2017-01-27T21:22:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.030666666666666665 + }, + "doc_count": 1077, + "key": 1485552150000, + "key_as_string": "2017-01-27T21:22:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03833333333333334 + }, + "doc_count": 1083, + "key": 1485552180000, + "key_as_string": "2017-01-27T21:23:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04966666666666667 + }, + "doc_count": 1083, + "key": 1485552210000, + "key_as_string": "2017-01-27T21:23:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.041 + }, + "doc_count": 1082, + "key": 1485552240000, + "key_as_string": "2017-01-27T21:24:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037333333333333336 + }, + "doc_count": 1087, + "key": 1485552270000, + "key_as_string": "2017-01-27T21:24:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.039 + }, + "doc_count": 1083, + "key": 1485552300000, + "key_as_string": "2017-01-27T21:25:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03933333333333333 + }, + "doc_count": 1083, + "key": 1485552330000, + "key_as_string": "2017-01-27T21:25:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037666666666666675 + }, + "doc_count": 1083, + "key": 1485552360000, + "key_as_string": "2017-01-27T21:26:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04033333333333333 + }, + "doc_count": 1083, + "key": 1485552390000, + "key_as_string": "2017-01-27T21:26:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03866666666666666 + }, + "doc_count": 1082, + "key": 1485552420000, + "key_as_string": "2017-01-27T21:27:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04033333333333334 + }, + "doc_count": 1083, + "key": 1485552450000, + "key_as_string": "2017-01-27T21:27:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04 + }, + "doc_count": 1083, + "key": 1485552480000, + "key_as_string": "2017-01-27T21:28:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04033333333333333 + }, + "doc_count": 1084, + "key": 1485552510000, + "key_as_string": "2017-01-27T21:28:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03333333333333333 + }, + "doc_count": 1083, + "key": 1485552540000, + "key_as_string": "2017-01-27T21:29:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03566666666666667 + }, + "doc_count": 1083, + "key": 1485552570000, + "key_as_string": "2017-01-27T21:29:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04466666666666667 + }, + "doc_count": 1083, + "key": 1485552600000, + "key_as_string": "2017-01-27T21:30:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03233333333333333 + }, + "doc_count": 1083, + "key": 1485552630000, + "key_as_string": "2017-01-27T21:30:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.0505 + }, + "doc_count": 722, + "key": 1485552660000, + "key_as_string": "2017-01-27T21:31:00.000Z" + } + ] + } + } + }, + "hits": { + "hits": [], + "max_score": 0, + "total": 128145 + }, + "status": 200, + "timed_out": false, + "took": 28 +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/fixtures/std_metric_fixture.json b/src/core_plugins/metrics/server/lib/vis_data/__tests__/fixtures/std_metric_fixture.json new file mode 100644 index 00000000000000..d587e48b318818 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/fixtures/std_metric_fixture.json @@ -0,0 +1,985 @@ +{ + "_shards": { + "failed": 0, + "successful": 5, + "total": 5 + }, + "aggregations": { + "c9b5f9c0-e403-11e6-be91-6f7688e9fac7": { + "doc_count": 128145, + "timeseries": { + "buckets": [ + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.057 + }, + "doc_count": 368, + "key": 1485549090000, + "key_as_string": "2017-01-27T20:31:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.07466666666666667 + }, + "doc_count": 1106, + "key": 1485549120000, + "key_as_string": "2017-01-27T20:32:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.08033333333333335 + }, + "doc_count": 1107, + "key": 1485549150000, + "key_as_string": "2017-01-27T20:32:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.066 + }, + "doc_count": 1109, + "key": 1485549180000, + "key_as_string": "2017-01-27T20:33:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.05366666666666667 + }, + "doc_count": 1093, + "key": 1485549210000, + "key_as_string": "2017-01-27T20:33:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04533333333333334 + }, + "doc_count": 1086, + "key": 1485549240000, + "key_as_string": "2017-01-27T20:34:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037333333333333336 + }, + "doc_count": 1086, + "key": 1485549270000, + "key_as_string": "2017-01-27T20:34:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1090, + "key": 1485549300000, + "key_as_string": "2017-01-27T20:35:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.036000000000000004 + }, + "doc_count": 1085, + "key": 1485549330000, + "key_as_string": "2017-01-27T20:35:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1082, + "key": 1485549360000, + "key_as_string": "2017-01-27T20:36:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1080, + "key": 1485549390000, + "key_as_string": "2017-01-27T20:36:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037000000000000005 + }, + "doc_count": 1082, + "key": 1485549420000, + "key_as_string": "2017-01-27T20:37:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.036000000000000004 + }, + "doc_count": 1079, + "key": 1485549450000, + "key_as_string": "2017-01-27T20:37:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03233333333333333 + }, + "doc_count": 1080, + "key": 1485549480000, + "key_as_string": "2017-01-27T20:38:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1080, + "key": 1485549510000, + "key_as_string": "2017-01-27T20:38:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1082, + "key": 1485549540000, + "key_as_string": "2017-01-27T20:39:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.042666666666666665 + }, + "doc_count": 1079, + "key": 1485549570000, + "key_as_string": "2017-01-27T20:39:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1077, + "key": 1485549600000, + "key_as_string": "2017-01-27T20:40:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03833333333333334 + }, + "doc_count": 1075, + "key": 1485549630000, + "key_as_string": "2017-01-27T20:40:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1076, + "key": 1485549660000, + "key_as_string": "2017-01-27T20:41:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1076, + "key": 1485549690000, + "key_as_string": "2017-01-27T20:41:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1074, + "key": 1485549720000, + "key_as_string": "2017-01-27T20:42:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1072, + "key": 1485549750000, + "key_as_string": "2017-01-27T20:42:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1067, + "key": 1485549780000, + "key_as_string": "2017-01-27T20:43:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.036000000000000004 + }, + "doc_count": 1065, + "key": 1485549810000, + "key_as_string": "2017-01-27T20:43:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1065, + "key": 1485549840000, + "key_as_string": "2017-01-27T20:44:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.036333333333333336 + }, + "doc_count": 1062, + "key": 1485549870000, + "key_as_string": "2017-01-27T20:44:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1063, + "key": 1485549900000, + "key_as_string": "2017-01-27T20:45:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1065, + "key": 1485549930000, + "key_as_string": "2017-01-27T20:45:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1065, + "key": 1485549960000, + "key_as_string": "2017-01-27T20:46:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1069, + "key": 1485549990000, + "key_as_string": "2017-01-27T20:46:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03333333333333333 + }, + "doc_count": 1068, + "key": 1485550020000, + "key_as_string": "2017-01-27T20:47:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1068, + "key": 1485550050000, + "key_as_string": "2017-01-27T20:47:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1068, + "key": 1485550080000, + "key_as_string": "2017-01-27T20:48:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1074, + "key": 1485550110000, + "key_as_string": "2017-01-27T20:48:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03133333333333333 + }, + "doc_count": 1074, + "key": 1485550140000, + "key_as_string": "2017-01-27T20:49:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1074, + "key": 1485550170000, + "key_as_string": "2017-01-27T20:49:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03133333333333333 + }, + "doc_count": 1073, + "key": 1485550200000, + "key_as_string": "2017-01-27T20:50:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1077, + "key": 1485550230000, + "key_as_string": "2017-01-27T20:50:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03166666666666667 + }, + "doc_count": 1074, + "key": 1485550260000, + "key_as_string": "2017-01-27T20:51:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.031 + }, + "doc_count": 1074, + "key": 1485550290000, + "key_as_string": "2017-01-27T20:51:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1072, + "key": 1485550320000, + "key_as_string": "2017-01-27T20:52:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.033 + }, + "doc_count": 1073, + "key": 1485550350000, + "key_as_string": "2017-01-27T20:52:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03466666666666667 + }, + "doc_count": 1071, + "key": 1485550380000, + "key_as_string": "2017-01-27T20:53:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1071, + "key": 1485550410000, + "key_as_string": "2017-01-27T20:53:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03166666666666667 + }, + "doc_count": 1069, + "key": 1485550440000, + "key_as_string": "2017-01-27T20:54:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1069, + "key": 1485550470000, + "key_as_string": "2017-01-27T20:54:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.032 + }, + "doc_count": 1068, + "key": 1485550500000, + "key_as_string": "2017-01-27T20:55:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1067, + "key": 1485550530000, + "key_as_string": "2017-01-27T20:55:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03233333333333333 + }, + "doc_count": 1065, + "key": 1485550560000, + "key_as_string": "2017-01-27T20:56:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1069, + "key": 1485550590000, + "key_as_string": "2017-01-27T20:56:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03166666666666667 + }, + "doc_count": 1068, + "key": 1485550620000, + "key_as_string": "2017-01-27T20:57:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03333333333333333 + }, + "doc_count": 1068, + "key": 1485550650000, + "key_as_string": "2017-01-27T20:57:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1068, + "key": 1485550680000, + "key_as_string": "2017-01-27T20:58:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1071, + "key": 1485550710000, + "key_as_string": "2017-01-27T20:58:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03333333333333333 + }, + "doc_count": 1074, + "key": 1485550740000, + "key_as_string": "2017-01-27T20:59:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1074, + "key": 1485550770000, + "key_as_string": "2017-01-27T20:59:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04 + }, + "doc_count": 1074, + "key": 1485550800000, + "key_as_string": "2017-01-27T21:00:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.032 + }, + "doc_count": 1076, + "key": 1485550830000, + "key_as_string": "2017-01-27T21:00:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1078, + "key": 1485550860000, + "key_as_string": "2017-01-27T21:01:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1077, + "key": 1485550890000, + "key_as_string": "2017-01-27T21:01:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03466666666666667 + }, + "doc_count": 1071, + "key": 1485550920000, + "key_as_string": "2017-01-27T21:02:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03466666666666667 + }, + "doc_count": 1071, + "key": 1485550950000, + "key_as_string": "2017-01-27T21:02:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03566666666666667 + }, + "doc_count": 1073, + "key": 1485550980000, + "key_as_string": "2017-01-27T21:03:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1071, + "key": 1485551010000, + "key_as_string": "2017-01-27T21:03:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03466666666666667 + }, + "doc_count": 1069, + "key": 1485551040000, + "key_as_string": "2017-01-27T21:04:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03566666666666667 + }, + "doc_count": 1068, + "key": 1485551070000, + "key_as_string": "2017-01-27T21:04:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1075, + "key": 1485551100000, + "key_as_string": "2017-01-27T21:05:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1074, + "key": 1485551130000, + "key_as_string": "2017-01-27T21:05:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03133333333333333 + }, + "doc_count": 1073, + "key": 1485551160000, + "key_as_string": "2017-01-27T21:06:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1071, + "key": 1485551190000, + "key_as_string": "2017-01-27T21:06:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03333333333333333 + }, + "doc_count": 1075, + "key": 1485551220000, + "key_as_string": "2017-01-27T21:07:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03133333333333333 + }, + "doc_count": 1071, + "key": 1485551250000, + "key_as_string": "2017-01-27T21:07:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04733333333333334 + }, + "doc_count": 1081, + "key": 1485551280000, + "key_as_string": "2017-01-27T21:08:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.044333333333333336 + }, + "doc_count": 1078, + "key": 1485551310000, + "key_as_string": "2017-01-27T21:08:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037000000000000005 + }, + "doc_count": 1079, + "key": 1485551340000, + "key_as_string": "2017-01-27T21:09:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1077, + "key": 1485551370000, + "key_as_string": "2017-01-27T21:09:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03866666666666666 + }, + "doc_count": 1077, + "key": 1485551400000, + "key_as_string": "2017-01-27T21:10:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037666666666666675 + }, + "doc_count": 1075, + "key": 1485551430000, + "key_as_string": "2017-01-27T21:10:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.038000000000000006 + }, + "doc_count": 1078, + "key": 1485551460000, + "key_as_string": "2017-01-27T21:11:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037 + }, + "doc_count": 1074, + "key": 1485551490000, + "key_as_string": "2017-01-27T21:11:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.036666666666666674 + }, + "doc_count": 1074, + "key": 1485551520000, + "key_as_string": "2017-01-27T21:12:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037333333333333336 + }, + "doc_count": 1076, + "key": 1485551550000, + "key_as_string": "2017-01-27T21:12:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03733333333333333 + }, + "doc_count": 1075, + "key": 1485551580000, + "key_as_string": "2017-01-27T21:13:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04533333333333334 + }, + "doc_count": 1077, + "key": 1485551610000, + "key_as_string": "2017-01-27T21:13:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.039 + }, + "doc_count": 1080, + "key": 1485551640000, + "key_as_string": "2017-01-27T21:14:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.057666666666666665 + }, + "doc_count": 1080, + "key": 1485551670000, + "key_as_string": "2017-01-27T21:14:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.045000000000000005 + }, + "doc_count": 1080, + "key": 1485551700000, + "key_as_string": "2017-01-27T21:15:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037666666666666675 + }, + "doc_count": 1080, + "key": 1485551730000, + "key_as_string": "2017-01-27T21:15:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1080, + "key": 1485551760000, + "key_as_string": "2017-01-27T21:16:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.038 + }, + "doc_count": 1080, + "key": 1485551790000, + "key_as_string": "2017-01-27T21:16:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1080, + "key": 1485551820000, + "key_as_string": "2017-01-27T21:17:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04966666666666667 + }, + "doc_count": 1080, + "key": 1485551850000, + "key_as_string": "2017-01-27T21:17:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03266666666666667 + }, + "doc_count": 1080, + "key": 1485551880000, + "key_as_string": "2017-01-27T21:18:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04 + }, + "doc_count": 1080, + "key": 1485551910000, + "key_as_string": "2017-01-27T21:18:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03766666666666666 + }, + "doc_count": 1080, + "key": 1485551940000, + "key_as_string": "2017-01-27T21:19:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034 + }, + "doc_count": 1076, + "key": 1485551970000, + "key_as_string": "2017-01-27T21:19:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.034333333333333334 + }, + "doc_count": 1077, + "key": 1485552000000, + "key_as_string": "2017-01-27T21:20:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03366666666666667 + }, + "doc_count": 1077, + "key": 1485552030000, + "key_as_string": "2017-01-27T21:20:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.029666666666666664 + }, + "doc_count": 1077, + "key": 1485552060000, + "key_as_string": "2017-01-27T21:21:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.02766666666666667 + }, + "doc_count": 1077, + "key": 1485552090000, + "key_as_string": "2017-01-27T21:21:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.028 + }, + "doc_count": 1077, + "key": 1485552120000, + "key_as_string": "2017-01-27T21:22:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.030666666666666665 + }, + "doc_count": 1077, + "key": 1485552150000, + "key_as_string": "2017-01-27T21:22:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03833333333333334 + }, + "doc_count": 1083, + "key": 1485552180000, + "key_as_string": "2017-01-27T21:23:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04966666666666667 + }, + "doc_count": 1083, + "key": 1485552210000, + "key_as_string": "2017-01-27T21:23:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.041 + }, + "doc_count": 1082, + "key": 1485552240000, + "key_as_string": "2017-01-27T21:24:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037333333333333336 + }, + "doc_count": 1087, + "key": 1485552270000, + "key_as_string": "2017-01-27T21:24:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.039 + }, + "doc_count": 1083, + "key": 1485552300000, + "key_as_string": "2017-01-27T21:25:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03933333333333333 + }, + "doc_count": 1083, + "key": 1485552330000, + "key_as_string": "2017-01-27T21:25:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.037666666666666675 + }, + "doc_count": 1083, + "key": 1485552360000, + "key_as_string": "2017-01-27T21:26:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04033333333333333 + }, + "doc_count": 1083, + "key": 1485552390000, + "key_as_string": "2017-01-27T21:26:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03866666666666666 + }, + "doc_count": 1082, + "key": 1485552420000, + "key_as_string": "2017-01-27T21:27:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04033333333333334 + }, + "doc_count": 1083, + "key": 1485552450000, + "key_as_string": "2017-01-27T21:27:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04 + }, + "doc_count": 1083, + "key": 1485552480000, + "key_as_string": "2017-01-27T21:28:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04033333333333333 + }, + "doc_count": 1084, + "key": 1485552510000, + "key_as_string": "2017-01-27T21:28:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03333333333333333 + }, + "doc_count": 1083, + "key": 1485552540000, + "key_as_string": "2017-01-27T21:29:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03566666666666667 + }, + "doc_count": 1083, + "key": 1485552570000, + "key_as_string": "2017-01-27T21:29:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.04466666666666667 + }, + "doc_count": 1083, + "key": 1485552600000, + "key_as_string": "2017-01-27T21:30:00.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.03233333333333333 + }, + "doc_count": 1083, + "key": 1485552630000, + "key_as_string": "2017-01-27T21:30:30.000Z" + }, + { + "c9b5f9c1-e403-11e6-be91-6f7688e9fac7": { + "value": 0.0505 + }, + "doc_count": 722, + "key": 1485552660000, + "key_as_string": "2017-01-27T21:31:00.000Z" + } + ] + } + } + }, + "hits": { + "hits": [], + "max_score": 0, + "total": 128145 + }, + "status": 200, + "timed_out": false, + "took": 28 +} + diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/get_interval_and_timefield.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/get_interval_and_timefield.js new file mode 100644 index 00000000000000..5ac5d5ac888531 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/get_interval_and_timefield.js @@ -0,0 +1,25 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import getIntervalAndTimefield from '../get_interval_and_timefield'; + +describe('getIntervalAndTimefield(panel, series)', () => { + + it('returns the panel interval and timefield', () => { + const panel = { time_field: '@timestamp', interval: 'auto' }; + const series = {}; + expect(getIntervalAndTimefield(panel, series)).to.eql({ + timeField: '@timestamp', + interval: 'auto' + }); + }); + + it('returns the series interval and timefield', () => { + const panel = { time_field: '@timestamp', interval: 'auto' }; + const series = { override_index_pattern: true, series_interval: '1m', series_time_field: 'time' }; + expect(getIntervalAndTimefield(panel, series)).to.eql({ + timeField: 'time', + interval: '1m' + }); + }); + +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/bucket_transform.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/bucket_transform.js new file mode 100644 index 00000000000000..82305fd41491ea --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/bucket_transform.js @@ -0,0 +1,354 @@ +import { expect } from 'chai'; +import bucketTransform from '../../helpers/bucket_transform'; + +describe('bucketTransform', () => { + + describe('count', () => { + it ('returns count agg', () => { + const metric = { id: 'test', type: 'count' }; + const fn = bucketTransform.count; + expect(fn(metric)).to.eql({ + bucket_script: { + buckets_path: { count: '_count' }, + script: { inline: 'count * 1', lang: 'expression' }, + gap_policy: 'skip' + } + }); + }); + }); + + describe('std metric', () => { + ['avg', 'max', 'min', 'sum', 'cardinality', 'value_count'].forEach(type => { + it (`returns ${type} agg`, () => { + const metric = { id: 'test', type: type, field: 'cpu.pct' }; + const fn = bucketTransform[type]; + const result = {}; + result[type] = { field: 'cpu.pct' }; + expect(fn(metric)).to.eql(result); + }); + }); + + it('throws error if type is missing', () => { + const run = () => bucketTransform.avg({ id: 'test', field: 'cpu.pct' }); + expect(run).to.throw(Error, 'Metric missing type'); + }); + + it('throws error if field is missing', () => { + const run = () => bucketTransform.avg({ id: 'test', type: 'avg' }); + expect(run).to.throw(Error, 'Metric missing field'); + }); + }); + + describe('extended stats', () => { + ['std_deviation', 'variance', 'sum_of_squares'].forEach(type => { + it (`returns ${type} agg`, () => { + const fn = bucketTransform[type]; + const metric = { id: 'test', type: type, field: 'cpu.pct' }; + expect(fn(metric)).to.eql({ extended_stats: { field: 'cpu.pct' } }); + }); + }); + + it('returns std_deviation agg with sigma', () => { + const fn = bucketTransform.std_deviation; + const metric = { id: 'test', type: 'std_deviation', field: 'cpu.pct', sigma: 2 }; + expect(fn(metric)).to.eql({ extended_stats: { field: 'cpu.pct', sigma: 2 } }); + }); + + it('throws error if type is missing', () => { + const run = () => bucketTransform.std_deviation({ id: 'test', field: 'cpu.pct' }); + expect(run).to.throw(Error, 'Metric missing type'); + }); + + it('throws error if field is missing', () => { + const run = () => bucketTransform.std_deviation({ id: 'test', type: 'avg' }); + expect(run).to.throw(Error, 'Metric missing field'); + }); + }); + + describe('percentiles', () => { + it('returns percentiles agg', () => { + const metric = { + id: 'test', + type: 'percentile', + field: 'cpu.pct', + percentiles: [ + { value: 50, mode: 'line' }, + { value: 10, mode: 'band', percentile: 90 } + ] + }; + const fn = bucketTransform.percentile; + expect(fn(metric)).to.eql({ + percentiles: { + field: 'cpu.pct', + percents: [ + 50, + 10, + 90 + ] + } + }); + + }); + + it('throws error if type is missing', () => { + const run = () => bucketTransform.percentile({ id: 'test', field: 'cpu.pct', percentiles: [{ value: 50, mode: 'line' }] }); + expect(run).to.throw(Error, 'Metric missing type'); + }); + + it('throws error if field is missing', () => { + const run = () => bucketTransform.percentile({ id: 'test', type: 'avg', percentiles: [{ value: 50, mode: 'line' }] }); + expect(run).to.throw(Error, 'Metric missing field'); + }); + + it('throws error if percentiles is missing', () => { + const run = () => bucketTransform.percentile({ id: 'test', type: 'avg', field: 'cpu.pct' }); + expect(run).to.throw(Error, 'Metric missing percentiles'); + }); + }); + + describe('derivative', () => { + it('returns derivative agg with defaults', () => { + const metric = { + id: '2', + type: 'derivative', + field: '1', + }; + const metrics = [{ id: '1', type: 'max', field: 'cpu.pct' }, metric]; + const fn = bucketTransform.derivative; + expect(fn(metric, metrics, '10s')).is.eql({ + derivative: { + buckets_path: '1', + gap_policy: 'skip', + unit: '10s' + } + }); + }); + + it('returns derivative agg with unit', () => { + const metric = { + id: '2', + type: 'derivative', + field: '1', + unit: '1s' + }; + const metrics = [{ id: '1', type: 'max', field: 'cpu.pct' }, metric]; + const fn = bucketTransform.derivative; + expect(fn(metric, metrics, '10s')).is.eql({ + derivative: { + buckets_path: '1', + gap_policy: 'skip', + unit: '1s' + } + }); + }); + + it('returns derivative agg with gap_policy', () => { + const metric = { + id: '2', + type: 'derivative', + field: '1', + gap_policy: 'zero_fill' + }; + const metrics = [{ id: '1', type: 'max', field: 'cpu.pct' }, metric]; + const fn = bucketTransform.derivative; + expect(fn(metric, metrics, '10s')).is.eql({ + derivative: { + buckets_path: '1', + gap_policy: 'zero_fill', + unit: '10s' + } + }); + }); + + it('throws error if type is missing', () => { + const run = () => bucketTransform.derivative({ id: 'test', field: 'cpu.pct' }); + expect(run).to.throw(Error, 'Metric missing type'); + }); + + it('throws error if field is missing', () => { + const run = () => bucketTransform.derivative({ id: 'test', type: 'derivative' }); + expect(run).to.throw(Error, 'Metric missing field'); + }); + }); + + describe('serial_diff', () => { + it('returns serial_diff agg with defaults', () => { + const metric = { + id: '2', + type: 'serial_diff', + field: '1', + }; + const metrics = [{ id: '1', type: 'max', field: 'cpu.pct' }, metric]; + const fn = bucketTransform.serial_diff; + expect(fn(metric, metrics)).is.eql({ + serial_diff: { + buckets_path: '1', + gap_policy: 'skip', + lag: 1 + } + }); + }); + + it('returns serial_diff agg with lag', () => { + const metric = { + id: '2', + type: 'serial_diff', + field: '1', + lag: 10 + }; + const metrics = [{ id: '1', type: 'max', field: 'cpu.pct' }, metric]; + const fn = bucketTransform.serial_diff; + expect(fn(metric, metrics)).is.eql({ + serial_diff: { + buckets_path: '1', + gap_policy: 'skip', + lag: 10 + } + }); + }); + + it('returns serial_diff agg with gap_policy', () => { + const metric = { + id: '2', + type: 'serial_diff', + field: '1', + gap_policy: 'zero_fill' + }; + const metrics = [{ id: '1', type: 'max', field: 'cpu.pct' }, metric]; + const fn = bucketTransform.serial_diff; + expect(fn(metric, metrics)).is.eql({ + serial_diff: { + buckets_path: '1', + gap_policy: 'zero_fill', + lag: 1 + } + }); + }); + + it('throws error if type is missing', () => { + const run = () => bucketTransform.serial_diff({ id: 'test', field: 'cpu.pct' }); + expect(run).to.throw(Error, 'Metric missing type'); + }); + + it('throws error if field is missing', () => { + const run = () => bucketTransform.serial_diff({ id: 'test', type: 'serial_diff' }); + expect(run).to.throw(Error, 'Metric missing field'); + }); + }); + + describe('cumulative_sum', () => { + it('returns cumulative_sum agg', () => { + const metric = { id: '2', type: 'cumulative_sum', field: '1' }; + const metrics = [{ id: '1', type: 'sum', field: 'cpu.pct' }, metric]; + const fn = bucketTransform.cumulative_sum; + expect(fn(metric, metrics, '10s')).is.eql({ + cumulative_sum: { buckets_path: '1' } + }); + }); + + it('throws error if type is missing', () => { + const run = () => bucketTransform.cumulative_sum({ id: 'test', field: 'cpu.pct' }); + expect(run).to.throw(Error, 'Metric missing type'); + }); + + it('throws error if field is missing', () => { + const run = () => bucketTransform.cumulative_sum({ id: 'test', type: 'cumulative_sum' }); + expect(run).to.throw(Error, 'Metric missing field'); + }); + }); + + describe('moving_average', () => { + it('returns moving_average agg with defaults', () => { + const metric = { id: '2', type: 'moving_average', field: '1' }; + const metrics = [{ id: '1', type: 'avg', field: 'cpu.pct' }, metric]; + const fn = bucketTransform.moving_average; + expect(fn(metric, metrics, '10s')).is.eql({ + moving_avg: { + buckets_path: '1', + model: 'simple', + gap_policy: 'skip' + } + }); + }); + + it('returns moving_average agg with options', () => { + const metric = { + id: '2', + type: 'moving_average', + field: '1', + model: 'holt_winters', + window: 10, + minimize: 1, + settings: 'alpha=0.9 beta=0.5' + }; + const metrics = [{ id: '1', type: 'avg', field: 'cpu.pct' }, metric]; + const fn = bucketTransform.moving_average; + expect(fn(metric, metrics, '10s')).is.eql({ + moving_avg: { + buckets_path: '1', + model: 'holt_winters', + gap_policy: 'skip', + window: 10, + minimize: true, + settings: { + alpha: 0.9, + beta: 0.5 + } + } + }); + }); + + it('throws error if type is missing', () => { + const run = () => bucketTransform.moving_average({ id: 'test', field: 'cpu.pct' }); + expect(run).to.throw(Error, 'Metric missing type'); + }); + + it('throws error if field is missing', () => { + const run = () => bucketTransform.moving_average({ id: 'test', type: 'moving_average' }); + expect(run).to.throw(Error, 'Metric missing field'); + }); + }); + + describe('calculation', () => { + it('returns calculation(bucket_script)', () => { + const metric = { + id: '2', + type: 'calculation', + script: 'params.idle != null ? 1 - params.idle : 0', + variables: [{ name: 'idle', field: '1' }] + }; + const metrics = [{ id: '1', type: 'avg', field: 'cpu.idle.pct' }, metric]; + const fn = bucketTransform.calculation; + expect(fn(metric, metrics, '10s')).is.eql({ + bucket_script: { + buckets_path: { + idle: '1' + }, + gap_policy: 'skip', + script: { + inline: 'params.idle != null ? 1 - params.idle : 0', + lang: 'painless' + } + } + }); + }); + + it('throws error if variables is missing', () => { + const run = () => bucketTransform.calculation({ + id: 'test', + type: 'calculation', + script: 'params.idle != null ? 1 - params.idle : null' + }); + expect(run).to.throw(Error, 'Metric missing variables'); + }); + + it('throws error if script is missing', () => { + const run = () => bucketTransform.calculation({ + id: 'test', + type: 'calculation', + variables: [{ field: '1', name: 'idle' }] + }); + expect(run).to.throw(Error, 'Metric missing script'); + }); + }); +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_agg_value.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_agg_value.js new file mode 100644 index 00000000000000..589ab1453ea22f --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_agg_value.js @@ -0,0 +1,79 @@ +import { expect } from 'chai'; +import getAggValue from '../../helpers/get_agg_value'; + +function testAgg(row, metric, expected) { + let name = metric.type; + if (metric.mode) name += `(${metric.mode})`; + if (metric.percent) name += `(${metric.percent})`; + it(`it should return ${name}`, () => { + const value = getAggValue(row, metric); + expect(value).to.eql(expected); + }); +} + +describe('getAggValue', () => { + + describe('extended_stats', () => { + const row = { + 'test': { + 'count': 9, + 'min': 72, + 'max': 99, + 'avg': 86, + 'sum': 774, + 'sum_of_squares': 67028, + 'variance': 51.55555555555556, + 'std_deviation': 7.180219742846005, + 'std_deviation_bounds': { + 'upper': 100.36043948569201, + 'lower': 71.63956051430799 + } + } + }; + testAgg(row, { id: 'test', type: 'std_deviation' }, 7.180219742846005); + testAgg(row, { id: 'test', type: 'variance' }, 51.55555555555556); + testAgg(row, { id: 'test', type: 'sum_of_squares' }, 67028); + testAgg(row, { id: 'test', type: 'std_deviation', mode: 'upper' }, 100.36043948569201); + testAgg(row, { id: 'test', type: 'std_deviation', mode: 'lower' }, 71.63956051430799); + }); + + describe('percentile', () => { + const row = { + 'test': { + 'values': { + '1.0': 15, + '5.0': 20, + '25.0': 23, + '50.0': 25, + '75.0': 29, + '95.0': 60, + '99.0': 150 + } + } + }; + testAgg(row, { id: 'test', type: 'percentile', percent: '50' }, 25); + testAgg(row, { id: 'test', type: 'percentile', percent: '1.0' }, 15); + }); + + const basicWithDerv = { + 'key_as_string': '2015/02/01 00:00:00', + 'key': 1422748800000, + 'doc_count': 2, + 'test': { + 'value': 60.0 + }, + 'test_deriv': { + 'value': -490.0, + 'normalized_value': -15.806451612903226 + } + }; + + describe('derivative', () => { + testAgg(basicWithDerv, { id: 'test_deriv', type: 'derivative' }, -15.806451612903226); + }); + + describe('basic metric', () => { + testAgg(basicWithDerv, { id: 'test', type: 'avg' }, 60.0); + }); + +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_bucket_size.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_bucket_size.js new file mode 100644 index 00000000000000..c22c744b1b2133 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_bucket_size.js @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import getBucketSize from '../../helpers/get_bucket_size'; + +describe('getBucketSize', () => { + const req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00.000Z', + max: '2017-01-01T01:00:00.000Z', + } + } + }; + + it('returns auto calulated buckets', () => { + const result = getBucketSize(req, 'auto'); + expect(result).to.have.property('bucketSize', 30); + expect(result).to.have.property('intervalString', '30s'); + }); + + it('returns overriden buckets (1s)', () => { + const result = getBucketSize(req, '1s'); + expect(result).to.have.property('bucketSize', 1); + expect(result).to.have.property('intervalString', '1s'); + }); + + it('returns overriden buckets (10m)', () => { + const result = getBucketSize(req, '10m'); + expect(result).to.have.property('bucketSize', 600); + expect(result).to.have.property('intervalString', '10m'); + }); + + it('returns overriden buckets (1d)', () => { + const result = getBucketSize(req, '1d'); + expect(result).to.have.property('bucketSize', 86400); + expect(result).to.have.property('intervalString', '1d'); + }); + +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_buckets_path.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_buckets_path.js new file mode 100644 index 00000000000000..fb9104e0e20d17 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_buckets_path.js @@ -0,0 +1,56 @@ +import { expect } from 'chai'; +import getBucketsPath from '../../helpers/get_buckets_path'; + +describe('getBucketsPath', () => { + + const metrics = [ + { id: 1, type: 'derivative' }, + { id: 2, type: 'percentile', percent: '50' }, + { id: 3, type: 'percentile', percent: '20.0' }, + { id: 4, type: 'std_deviation', mode: 'raw' }, + { id: 5, type: 'std_deviation', mode: 'upper' }, + { id: 6, type: 'std_deviation', mode: 'lower' }, + { id: 7, type: 'sum_of_squares' }, + { id: 8, type: 'variance' }, + { id: 9, type: 'max' } + ]; + + it('return path for derivative', () => { + expect(getBucketsPath(1, metrics)).to.equal('1[normalized_value]'); + }); + + it('return path for percentile(50)', () => { + expect(getBucketsPath(2, metrics)).to.equal('2[50.0]'); + }); + + it('return path for percentile(20.0)', () => { + expect(getBucketsPath(3, metrics)).to.equal('3[20.0]'); + }); + + it('return path for std_deviation(raw)', () => { + expect(getBucketsPath(4, metrics)).to.equal('4[std_deviation]'); + }); + + it('return path for std_deviation(upper)', () => { + expect(getBucketsPath(5, metrics)).to.equal('5[std_upper]'); + }); + + it('return path for std_deviation(lower)', () => { + expect(getBucketsPath(6, metrics)).to.equal('6[std_lower]'); + }); + + it('return path for sum_of_squares', () => { + expect(getBucketsPath(7, metrics)).to.equal('7[sum_of_squares]'); + }); + + it('return path for variance', () => { + expect(getBucketsPath(8, metrics)).to.equal('8[variance]'); + }); + + it('return path for basic metric', () => { + expect(getBucketsPath(9, metrics)).to.equal('9'); + }); + + +}); + diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_default_decoration.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_default_decoration.js new file mode 100644 index 00000000000000..8976e55e099b65 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_default_decoration.js @@ -0,0 +1,95 @@ +import { expect } from 'chai'; +import getDefaultDecoration from '../../helpers/get_default_decoration'; + +describe('getDefaultDecoration', () => { + + describe('lines', () => { + it('return decoration for lines', () => { + const series = { + point_size: 10, + chart_type: 'line', + line_width: 10, + fill: 1 + }; + const result = getDefaultDecoration(series); + expect(result.lines) + .to.have.property('show', true); + expect(result.lines) + .to.have.property('fill', 1); + expect(result.lines) + .to.have.property('lineWidth', 10); + expect(result.points) + .to.have.property('show', true); + expect(result.points) + .to.have.property('radius', 1); + expect(result.points) + .to.have.property('lineWidth', 10); + expect(result.bars) + .to.have.property('show', false); + expect(result.bars) + .to.have.property('fill', 1); + expect(result.bars) + .to.have.property('lineWidth', 10); + }); + + it('return decoration for lines without points', () => { + const series = { + chart_type: 'line', + line_width: 10, + fill: 1 + }; + const result = getDefaultDecoration(series); + expect(result.points) + .to.have.property('show', true); + expect(result.points) + .to.have.property('lineWidth', 10); + }); + + it('return decoration for lines with points set to zero (off)', () => { + const series = { + chart_type: 'line', + line_width: 10, + fill: 1, + point_size: 0 + }; + const result = getDefaultDecoration(series); + expect(result.points) + .to.have.property('show', false); + }); + + it('return decoration for lines (off)', () => { + const series = { + chart_type: 'line', + line_width: 0, + }; + const result = getDefaultDecoration(series); + expect(result.lines) + .to.have.property('show', false); + }); + }); + + describe('bars', () => { + + it('return decoration for bars', () => { + const series = { + chart_type: 'bar', + line_width: 10, + fill: 1 + }; + const result = getDefaultDecoration(series); + expect(result.lines) + .to.have.property('show', false); + expect(result.points) + .to.have.property('show', false); + expect(result.bars) + .to.have.property('show', true); + expect(result.bars) + .to.have.property('fill', 1); + expect(result.bars) + .to.have.property('lineWidth', 10); + }); + + }); + +}); + diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_last_metric.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_last_metric.js new file mode 100644 index 00000000000000..d60dcdb8895c70 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_last_metric.js @@ -0,0 +1,23 @@ +import { expect } from 'chai'; +import getLastMetric from '../../helpers/get_last_metric'; + +describe('getLastMetric(series)', () => { + it('returns the last metric', () => { + const series = { + metrics: [ + { id: 1, type: 'avg' }, + { id: 2, type: 'moving_average' } + ] + }; + expect(getLastMetric(series)).to.eql({ id: 2, type: 'moving_average' }); + }); + it('returns the last metric that not a series_agg', () => { + const series = { + metrics: [ + { id: 1, type: 'avg' }, + { id: 2, type: 'series_agg' } + ] + }; + expect(getLastMetric(series)).to.eql({ id: 1, type: 'avg' }); + }); +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_sibling_agg_value.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_sibling_agg_value.js new file mode 100644 index 00000000000000..1e98b15b03467d --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_sibling_agg_value.js @@ -0,0 +1,36 @@ +import { expect } from 'chai'; +import getSiblingAggValue from '../../helpers/get_sibling_agg_value'; + +describe('getSiblingAggValue', () => { + const row = { + test: { + max: 3, + std_deviation: 1.5, + std_deviation_bounds: { + upper: 2, + lower: 1 + } + } + }; + + it('returns the value for std_deviation_bounds.upper', () => { + const metric = { id: 'test', type: 'std_deviation_bucket', mode: 'upper' }; + expect(getSiblingAggValue(row, metric)).to.equal(2); + }); + + it('returns the value for std_deviation_bounds.lower', () => { + const metric = { id: 'test', type: 'std_deviation_bucket', mode: 'lower' }; + expect(getSiblingAggValue(row, metric)).to.equal(1); + }); + + it('returns the value for std_deviation', () => { + const metric = { id: 'test', type: 'std_deviation_bucket', mode: 'raw' }; + expect(getSiblingAggValue(row, metric)).to.equal(1.5); + }); + + it('returns the value for basic (max)', () => { + const metric = { id: 'test', type: 'max_bucket' }; + expect(getSiblingAggValue(row, metric)).to.equal(3); + }); + +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_splits.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_splits.js new file mode 100644 index 00000000000000..024469d5442f1c --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_splits.js @@ -0,0 +1,130 @@ +import { expect } from 'chai'; +import getSplits from '../../helpers/get_splits'; + +describe('getSplits(resp, series)', () => { + + it('should return a splits for everything/filter group bys', () => { + const resp = { + aggregations: { + SERIES: { + timeseries: { buckets: [] }, + SIBAGG: { value: 1 } + } + } + }; + const series = { + id: 'SERIES', + color: '#F00', + split_mode: 'everything', + metrics: [ + { id: 'AVG', type: 'avg', field: 'cpu' }, + { id: 'SIBAGG', type: 'avg_bucket', field: 'AVG' } + ] + }; + expect(getSplits(resp, series)).to.eql([ + { + id: 'SERIES', + label: 'Overall Average of Average of cpu', + color: '#FF0000', + timeseries: { buckets: [] }, + SIBAGG: { value: 1 } + } + ]); + }); + + it('should return a splits for terms group bys', () => { + const resp = { + aggregations: { + SERIES: { + buckets: [ + { + key: 'example-01', + timeseries: { buckets: [] }, + SIBAGG: { value: 1 } + }, + { + key: 'example-02', + timeseries: { buckets: [] }, + SIBAGG: { value: 2 } + } + ] + } + } + }; + const series = { + id: 'SERIES', + color: '#F00', + split_mode: 'terms', + terms_field: 'beat.hostname', + terms_size: 10, + metrics: [ + { id: 'AVG', type: 'avg', field: 'cpu' }, + { id: 'SIBAGG', type: 'avg_bucket', field: 'AVG' } + ] + }; + expect(getSplits(resp, series)).to.eql([ + { + id: 'SERIES:example-01', + key: 'example-01', + label: 'example-01', + color: '#FF0000', + timeseries: { buckets: [] }, + SIBAGG: { value: 1 } + }, + { + id: 'SERIES:example-02', + key: 'example-02', + label: 'example-02', + color: '#930000', + timeseries: { buckets: [] }, + SIBAGG: { value: 2 } + } + ]); + }); + + it('should return a splits for filters group bys', () => { + const resp = { + aggregations: { + SERIES: { + buckets: { + 'filter-1': { + timeseries: { buckets: [] }, + }, + 'filter-2': { + timeseries: { buckets: [] }, + } + } + } + } + }; + const series = { + id: 'SERIES', + color: '#F00', + split_mode: 'filters', + split_filters: [ + { id: 'filter-1', color: '#F00', filter: 'status_code:[* TO 200]', label: '200s' }, + { id: 'filter-2', color: '#0F0', filter: 'status_code:[300 TO *]', label: '300s' } + ], + metrics: [ + { id: 'COUNT', type: 'count' }, + ] + }; + expect(getSplits(resp, series)).to.eql([ + { + id: 'SERIES:filter-1', + key: 'filter-1', + label: '200s', + color: '#F00', + timeseries: { buckets: [] }, + }, + { + id: 'SERIES:filter-2', + key: 'filter-2', + label: '300s', + color: '#0F0', + timeseries: { buckets: [] }, + } + ]); + }); + +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_timerange.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_timerange.js new file mode 100644 index 00000000000000..81c62fa245f11a --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/get_timerange.js @@ -0,0 +1,24 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import getTimerange from '../../helpers/get_timerange'; +import moment from 'moment'; + +describe('getTimerange(req)', () => { + it('should return a moment object for to and from', () => { + const req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z' + } + } + }; + const { from, to } = getTimerange(req); + expect(moment.isMoment(from)).to.equal(true); + expect(moment.isMoment(to)).to.equal(true); + expect(moment.utc('2017-01-01T00:00:00Z') + .isSame(from)).to.equal(true); + expect(moment.utc('2017-01-01T01:00:00Z') + .isSame(to)).to.equal(true); + }); +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/map_bucket.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/map_bucket.js new file mode 100644 index 00000000000000..bdffbe7fd13983 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/map_bucket.js @@ -0,0 +1,37 @@ +import mapBucket from '../../helpers/map_bucket'; +import { expect } from 'chai'; + +describe('mapBucket(metric)', () => { + it('returns bucket key and value for basic metric', () => { + const metric = { id: 'AVG', type: 'avg' }; + const bucket = { + key: 1234, + AVG: { value: 1 } + }; + expect(mapBucket(metric)(bucket)).to.eql([1234, 1]); + }); + it('returns bucket key and value for std_deviation', () => { + const metric = { id: 'STDDEV', type: 'std_deviation' }; + const bucket = { + key: 1234, + STDDEV: { std_deviation: 1 } + }; + expect(mapBucket(metric)(bucket)).to.eql([1234, 1]); + }); + it('returns bucket key and value for percentiles', () => { + const metric = { id: 'PCT', type: 'percentile', percent: 50 }; + const bucket = { + key: 1234, + PCT: { values: { '50.0': 1 } } + }; + expect(mapBucket(metric)(bucket)).to.eql([1234, 1]); + }); + it('returns bucket key and value for derivative', () => { + const metric = { id: 'DERV', type: 'derivative', field: 'io', unit: '1s' }; + const bucket = { + key: 1234, + DERV: { value: 100, normalized_value: 1 } + }; + expect(mapBucket(metric)(bucket)).to.eql([1234, 1]); + }); +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/parse_settings.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/parse_settings.js new file mode 100644 index 00000000000000..ce62a61a9e0635 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/helpers/parse_settings.js @@ -0,0 +1,51 @@ +import { expect } from 'chai'; +import parseSettings from '../../helpers/parse_settings'; + +describe('parseSettings', () => { + it('returns the true for "true"', () => { + const settings = 'pad=true'; + expect(parseSettings(settings)).to.eql({ + pad: true, + }); + }); + + it('returns the false for "false"', () => { + const settings = 'pad=false'; + expect(parseSettings(settings)).to.eql({ + pad: false, + }); + }); + + it('returns the true for "true"', () => { + const settings = 'pad=true'; + expect(parseSettings(settings)).to.eql({ + pad: true, + }); + }); + + it('returns the true for 1', () => { + const settings = 'pad=1'; + expect(parseSettings(settings)).to.eql({ + pad: true, + }); + }); + + it('returns the false for 0', () => { + const settings = 'pad=0'; + expect(parseSettings(settings)).to.eql({ + pad: false, + }); + }); + + it('returns the settings as an object', () => { + const settings = 'alpha=0.9 beta=0.4 gamma=0.2 period=5 pad=false type=add'; + expect(parseSettings(settings)).to.eql({ + alpha: 0.9, + beta: 0.4, + gamma: 0.2, + period: 5, + pad: false, + type: 'add' + }); + }); +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/__tests__/offset_time.js b/src/core_plugins/metrics/server/lib/vis_data/__tests__/offset_time.js new file mode 100644 index 00000000000000..d34415d0f83cdb --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/__tests__/offset_time.js @@ -0,0 +1,44 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import moment from 'moment'; +import offsetTime from '../offset_time'; + +describe('offsetTime(req, by)', () => { + it('should return a moment object for to and from', () => { + const req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z' + } + } + }; + const { from, to } = offsetTime(req, ''); + expect(moment.isMoment(from)).to.equal(true); + expect(moment.isMoment(to)).to.equal(true); + expect(moment.utc('2017-01-01T00:00:00Z') + .isSame(from)).to.equal(true); + expect(moment.utc('2017-01-01T01:00:00Z') + .isSame(to)).to.equal(true); + }); + + it('should return a moment object for to and from offset by 1 hour', () => { + const req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z' + } + } + }; + const { from, to } = offsetTime(req, '1h'); + expect(moment.isMoment(from)).to.equal(true); + expect(moment.isMoment(to)).to.equal(true); + expect(moment.utc('2017-01-01T00:00:00Z').subtract(1, 'h') + .isSame(from)).to.equal(true); + expect(moment.utc('2017-01-01T01:00:00Z').subtract(1, 'h') + .isSame(to)).to.equal(true); + }); + +}); + diff --git a/src/core_plugins/metrics/server/lib/vis_data/build_annotation_request.js b/src/core_plugins/metrics/server/lib/vis_data/build_annotation_request.js new file mode 100644 index 00000000000000..0f263339077c0c --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/build_annotation_request.js @@ -0,0 +1,8 @@ +import buildProcessorFunction from './build_processor_function'; +import processors from './request_processors/annotations'; + +export default function buildAnnotationRequest(req, panel, annotation) { + const processor = buildProcessorFunction(processors, req, panel, annotation); + const doc = processor({}); + return doc; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/build_processor_function.js b/src/core_plugins/metrics/server/lib/vis_data/build_processor_function.js new file mode 100644 index 00000000000000..bb07145f8c7d6d --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/build_processor_function.js @@ -0,0 +1,5 @@ +export default function buildProcessorFunction(chain, ...args) { + return chain.reduceRight((next, fn) => { + return fn(...args)(next); + }, doc => doc); +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/build_request_body.js b/src/core_plugins/metrics/server/lib/vis_data/build_request_body.js new file mode 100644 index 00000000000000..c64734b505d435 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/build_request_body.js @@ -0,0 +1,10 @@ +import buildProcessorFunction from './build_processor_function'; +import processors from './request_processors/series'; + +function buildRequestBody(req, panel, series) { + const processor = buildProcessorFunction(processors, req, panel, series); + const doc = processor({}); + return doc; +} + +export default buildRequestBody; diff --git a/src/core_plugins/metrics/server/lib/vis_data/calculate_indices.js b/src/core_plugins/metrics/server/lib/vis_data/calculate_indices.js new file mode 100644 index 00000000000000..bb9852a7d99b8e --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/calculate_indices.js @@ -0,0 +1,46 @@ +import _ from 'lodash'; +import offsetTime from './offset_time'; + +function getParams(req, indexPattern, timeField, offsetBy) { + + const { from, to } = offsetTime(req, offsetBy); + + const indexConstraints = {}; + indexConstraints[timeField] = { + max_value: { gte: from.toISOString() }, + min_value: { lte: to.toISOString() } + }; + + return { + index: indexPattern, + level: 'indices', + ignoreUnavailable: true, + body: { + fields: [timeField], + index_constraints: indexConstraints + } + }; + +} + +function handleResponse(resp) { + const indices = _.map(resp.indices, (_info, index) => index); + if (indices.length === 0) { + // there are no relevant indices for the given timeframe in the data + return []; + } + return indices; +} + +function calculateIndices(req, indexPattern = '*', timeField = '@timestamp', offsetBy) { + const { server } = req; + const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); + const params = getParams(req, indexPattern, timeField, offsetBy); + return callWithRequest(req, 'fieldStats', params) + .then(handleResponse); +} + + +calculateIndices.handleResponse = handleResponse; +calculateIndices.getParams = getParams; +export default calculateIndices; diff --git a/src/core_plugins/metrics/server/lib/vis_data/get_annotations.js b/src/core_plugins/metrics/server/lib/vis_data/get_annotations.js new file mode 100644 index 00000000000000..f578e6eee9060e --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/get_annotations.js @@ -0,0 +1,59 @@ +import calculateIndices from './calculate_indices'; +import buildAnnotationRequest from './build_annotation_request'; +import handleAnnotationResponse from './handle_annotation_response'; + +function validAnnotation(annotation) { + return annotation.index_pattern && + annotation.time_field && + annotation.fields && + annotation.icon && + annotation.template; +} + +export default (req, panel) => { + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('data'); + return Promise.all(panel.annotations + .filter(validAnnotation) + .map(annotation => { + + const indexPattern = annotation.index_pattern; + const timeField = annotation.time_field; + + return calculateIndices(req, indexPattern, timeField).then(indices => { + const bodies = []; + + if (!indices.length) throw new Error('missing-indices'); + bodies.push({ + index: indices, + ignore: [404], + timeout: '90s', + requestTimeout: 90000, + ignoreUnavailable: true, + }); + + bodies.push(buildAnnotationRequest(req, panel, annotation)); + return bodies; + }); + })) + .then(bodies => { + if (!bodies.length) return { responses: [] }; + return callWithRequest(req, 'msearch', { + body: bodies.reduce((acc, item) => acc.concat(item), []) + }); + }) + .then(resp => { + const results = {}; + panel.annotations + .filter(validAnnotation) + .forEach((annotation, index) => { + const data = resp.responses[index]; + results[annotation.id] = handleAnnotationResponse(data, annotation); + }); + return results; + }) + .catch(error => { + if (error.message === 'missing-indices') return {}; + throw error; + }); +}; + diff --git a/src/core_plugins/metrics/server/lib/vis_data/get_interval_and_timefield.js b/src/core_plugins/metrics/server/lib/vis_data/get_interval_and_timefield.js new file mode 100644 index 00000000000000..52ebf91997beff --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/get_interval_and_timefield.js @@ -0,0 +1,5 @@ +export default function getIntervalAndTimefield(panel, series) { + const timeField = series.override_index_pattern && series.series_time_field || panel.time_field; + const interval = series.override_index_pattern && series.series_interval || panel.interval; + return { timeField, interval }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/get_panel_data.js b/src/core_plugins/metrics/server/lib/vis_data/get_panel_data.js new file mode 100644 index 00000000000000..59a8351ece7685 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/get_panel_data.js @@ -0,0 +1,34 @@ +import getRequestParams from './get_request_params'; +import handleResponseBody from './handle_response_body'; +import handleErrorResponse from './handle_error_response'; +import getAnnotations from './get_annotations'; +export default function getPanelData(req) { + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('data'); + return panel => { + + return Promise.all(panel.series.map(series => getRequestParams(req, panel, series))) + .then((bodies) => { + const params = { + body: bodies.reduce((acc, items) => acc.concat(items), []) + }; + return callWithRequest(req, 'msearch', params); + }) + .then(resp => { + const series = resp.responses.map(handleResponseBody(panel)); + return { + [panel.id]: { + id: panel.id, + series: series.reduce((acc, series) => acc.concat(series), []) + } + }; + }) + .then(resp => { + if (!panel.annotations) return resp; + return getAnnotations(req, panel).then(annotations => { + resp[panel.id].annotations = annotations; + return resp; + }); + }) + .catch(handleErrorResponse(panel)); + }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/get_request_params.js b/src/core_plugins/metrics/server/lib/vis_data/get_request_params.js new file mode 100644 index 00000000000000..1a51b395e76c76 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/get_request_params.js @@ -0,0 +1,23 @@ +import calculateIndices from './calculate_indices'; +import buildRequestBody from './build_request_body'; +import getIntervalAndTimefield from './get_interval_and_timefield'; + +export default (req, panel, series) => { + const indexPattern = series.override_index_pattern && series.series_index_pattern || panel.index_pattern; + const { timeField, interval } = getIntervalAndTimefield(panel, series); + + return calculateIndices(req, indexPattern, timeField, series.offset_time).then(indices => { + const bodies = []; + + bodies.push({ + index: indices, + ignore: [404], + timeout: '90s', + requestTimeout: 90000, + ignoreUnavailable: true, + }); + + bodies.push(buildRequestBody(req, panel, series)); + return bodies; + }); +}; diff --git a/src/core_plugins/metrics/server/lib/vis_data/handle_annotation_response.js b/src/core_plugins/metrics/server/lib/vis_data/handle_annotation_response.js new file mode 100644 index 00000000000000..c5c92519a310fc --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/handle_annotation_response.js @@ -0,0 +1,11 @@ +import _ from 'lodash'; +export default function handleAnnotationResponse(resp, annotation) { + return _.get(resp, `aggregations.${annotation.id}.buckets`, []) + .filter(bucket => bucket.hits.hits.total) + .map((bucket) => { + return { + key: bucket.key, + docs: bucket.hits.hits.hits.map(doc => doc._source) + }; + }); +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/handle_error_response.js b/src/core_plugins/metrics/server/lib/vis_data/handle_error_response.js new file mode 100644 index 00000000000000..d37cd7592df5df --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/handle_error_response.js @@ -0,0 +1,17 @@ +export default panel => error => { + console.log(error); + const result = {}; + let errorResponse; + try { + errorResponse = JSON.parse(error.response); + } catch (e) { + errorResponse = error.response; + } + result[panel.id] = { + id: panel.id, + statusCode: error.statusCode, + error: errorResponse || error, + series: [] + }; + return result; +}; diff --git a/src/core_plugins/metrics/server/lib/vis_data/handle_response_body.js b/src/core_plugins/metrics/server/lib/vis_data/handle_response_body.js new file mode 100644 index 00000000000000..a2d531bed213dc --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/handle_response_body.js @@ -0,0 +1,18 @@ +import buildProcessorFunction from './build_processor_function'; +import processors from './response_processors/series'; + +export default function handleResponseBody(panel) { + return resp => { + if (resp.error) { + const err = new Error(resp.error.type); + err.response = JSON.stringify(resp); + throw err; + } + const keys = Object.keys(resp.aggregations); + if (keys.length !== 1) throw Error('There should only be one series per request.'); + const seriesId = keys[0]; + const series = panel.series.find(s => s.id === seriesId); + const processor = buildProcessorFunction(processors, resp, panel, series); + return processor([]); + }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/bucket_transform.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/bucket_transform.js new file mode 100644 index 00000000000000..76ad7cda12f1fc --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/bucket_transform.js @@ -0,0 +1,173 @@ +import _ from 'lodash'; +import parseSettings from './parse_settings'; +import getBucketsPath from './get_buckets_path'; +function checkMetric(metric, fields) { + fields.forEach(field => { + if (!metric[field]) { + throw new Error(`Metric missing ${field}`); + } + }); +} + +function stdMetric(bucket) { + checkMetric(bucket, ['type', 'field']); + const body = {}; + body[bucket.type] = { + field: bucket.field + }; + return body; +} + +function extendStats(bucket) { + checkMetric(bucket, ['type', 'field']); + const body = { + extended_stats: { field: bucket.field } + }; + if (bucket.sigma) body.extended_stats.sigma = parseInt(bucket.sigma, 10); + return body; +} + +function extendStatsBucket(bucket, metrics, bucketSize) { + const bucketsPath = 'timeseries > ' + getBucketsPath(bucket.field, metrics); + const body = { extended_stats_bucket: { buckets_path: bucketsPath } }; + if (bucket.sigma) body.extended_stats_bucket.sigma = parseInt(bucket.sigma, 10); + return body; +} + +export default { + count: (bucket) => { + return { + bucket_script: { + buckets_path: { count: '_count' }, + script: { + inline: 'count * 1', + lang: 'expression' + }, + gap_policy: 'skip' + } + }; + }, + avg: stdMetric, + max: stdMetric, + min: stdMetric, + sum: stdMetric, + cardinality: stdMetric, + value_count: stdMetric, + sum_of_squares: extendStats, + variance: extendStats, + std_deviation: extendStats, + + percentile_rank: bucket => { + checkMetric(bucket, ['type', 'field', 'value']); + const body = { + percentile_ranks: { + field: bucket.field, + values: [bucket.value] + } + }; + return body; + }, + + avg_bucket: extendStatsBucket, + max_bucket: extendStatsBucket, + min_bucket: extendStatsBucket, + sum_bucket: extendStatsBucket, + sum_of_squares_bucket: extendStatsBucket, + std_deviation_bucket: extendStatsBucket, + variance_bucket: extendStatsBucket, + + percentile: (bucket) => { + checkMetric(bucket, ['type', 'field', 'percentiles']); + let percents = bucket.percentiles.filter(p => p.value != null).map(p => p.value); + if (bucket.percentiles.some(p => p.mode === 'band')) { + percents = percents.concat(bucket.percentiles + .filter(p => p.percentile) + .map(p => p.percentile)); + } + const agg = { + percentiles: { + field: bucket.field, + percents + } + }; + return agg; + }, + + derivative: (bucket, metrics, bucketSize) => { + checkMetric(bucket, ['type', 'field']); + const metric = _.find(metrics, { id: bucket.field }); + const body = { + derivative: { + buckets_path: getBucketsPath(bucket.field, metrics), + gap_policy: 'skip', // seems sane + unit: bucketSize + } + }; + if (bucket.gap_policy) body.derivative.gap_policy = bucket.gap_policy; + if (bucket.unit) body.derivative.unit = /^([\d]+)([shmdwMy]|ms)$/.test(bucket.unit) ? bucket.unit : bucketSize; + return body; + }, + + serial_diff: (bucket, metrics, bucketSize) => { + checkMetric(bucket, ['type', 'field']); + const metric = _.find(metrics, { id: bucket.field }); + const body = { + serial_diff: { + buckets_path: getBucketsPath(bucket.field, metrics), + gap_policy: 'skip', // seems sane + lag: 1 + } + }; + if (bucket.gap_policy) body.serial_diff.gap_policy = bucket.gap_policy; + if (bucket.lag) body.serial_diff.lag = /^([\d]+)$/.test(bucket.lag) ? bucket.lag : 0; + return body; + }, + + cumulative_sum: (bucket, metrics) => { + checkMetric(bucket, ['type', 'field']); + const metric = _.find(metrics, { id: bucket.field }); + return { + cumulative_sum: { + buckets_path: getBucketsPath(bucket.field, metrics) + } + }; + }, + + moving_average: (bucket, metrics) => { + checkMetric(bucket, ['type', 'field']); + const metric = _.find(metrics, { id: bucket.field }); + const body = { + moving_avg: { + buckets_path: getBucketsPath(bucket.field, metrics), + model: bucket.model || 'simple', + gap_policy: 'skip' // seems sane + } + }; + if (bucket.gap_policy) body.moving_avg.gap_policy = bucket.gap_policy; + if (bucket.window) body.moving_avg.window = Number(bucket.window); + if (bucket.minimize) body.moving_avg.minimize = Boolean(bucket.minimize); + if (bucket.settings) body.moving_avg.settings = parseSettings(bucket.settings); + return body; + }, + + calculation: (bucket, metrics) => { + checkMetric(bucket, ['variables', 'script']); + const body = { + bucket_script: { + buckets_path: bucket.variables.reduce((acc, row) => { + const metric = _.find(metrics, { id: row.field }); + acc[row.name] = getBucketsPath(row.field, metrics); + return acc; + }, {}), + script: { + inline: bucket.script, + lang: 'painless' + }, + gap_policy: 'skip' // seems sane + } + }; + if (bucket.gap_policy) body.bucket_script.gap_policy = bucket.gap_policy; + return body; + } +}; + diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/calculate_auto.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/calculate_auto.js new file mode 100644 index 00000000000000..db754f9c164c23 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/calculate_auto.js @@ -0,0 +1,70 @@ +import moment from 'moment'; +const d = moment.duration; + +const roundingRules = [ + [ d(500, 'ms'), d(100, 'ms') ], + [ d(5, 'second'), d(1, 'second') ], + [ d(7.5, 'second'), d(5, 'second') ], + [ d(15, 'second'), d(10, 'second') ], + [ d(45, 'second'), d(30, 'second') ], + [ d(3, 'minute'), d(1, 'minute') ], + [ d(9, 'minute'), d(5, 'minute') ], + [ d(20, 'minute'), d(10, 'minute') ], + [ d(45, 'minute'), d(30, 'minute') ], + [ d(2, 'hour'), d(1, 'hour') ], + [ d(6, 'hour'), d(3, 'hour') ], + [ d(24, 'hour'), d(12, 'hour') ], + [ d(1, 'week'), d(1, 'd') ], + [ d(3, 'week'), d(1, 'week') ], + [ d(1, 'year'), d(1, 'month') ], + [ Infinity, d(1, 'year') ] +]; + +const revRoundingRules = roundingRules.slice(0).reverse(); + +function find(rules, check, last) { + function pick(buckets, duration) { + const target = duration / buckets; + let lastResp = null; + + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + const resp = check(rule[0], rule[1], target); + + if (resp == null) { + if (!last) continue; + if (lastResp) return lastResp; + break; + } + + if (!last) return resp; + lastResp = resp; + } + + // fallback to just a number of milliseconds, ensure ms is >= 1 + const ms = Math.max(Math.floor(target), 1); + return moment.duration(ms, 'ms'); + } + + return (buckets, duration) => { + const interval = pick(buckets, duration); + if (interval) return moment.duration(interval._data); + }; +} + +module.exports = { + near: find(revRoundingRules, function near(bound, interval, target) { + if (bound > target) return interval; + }, true), + + lessThan: find(revRoundingRules, function lessThan(_bound, interval, target) { + if (interval < target) return interval; + }), + + atLeast: find(revRoundingRules, function atLeast(_bound, interval, target) { + if (interval <= target) return interval; + }), +}; + + + diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/extended_stats_types.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/extended_stats_types.js new file mode 100644 index 00000000000000..16847b677869a1 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/extended_stats_types.js @@ -0,0 +1,7 @@ +export default [ + 'std_deviation', + 'variance', + 'sum_of_squares' +]; + + diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/get_agg_value.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_agg_value.js new file mode 100644 index 00000000000000..560d6e44d2a328 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_agg_value.js @@ -0,0 +1,35 @@ +import _ from 'lodash'; +import extendStatsTypes from './extended_stats_types'; +export default (row, metric) => { + // Extended Stats + if (_.includes(extendStatsTypes, metric.type)) { + const isStdDeviation = /^std_deviation/.test(metric.type); + const modeIsBounds = ~['upper','lower'].indexOf(metric.mode); + if (isStdDeviation && modeIsBounds) { + return _.get(row, `${metric.id}.std_deviation_bounds.${metric.mode}`); + } + return _.get(row, `${metric.id}.${metric.type}`); + } + + // Percentiles + if (metric.type === 'percentile') { + let percentileKey = `${metric.percent}`; + if (!/\./.test(`${metric.percent}`)) { + percentileKey = `${metric.percent}.0`; + } + return row[metric.id].values[percentileKey]; + } + + if (metric.type === 'percentile_rank') { + const percentileRankKey = `${metric.value}`; + return row[metric.id] && row[metric.id].values[percentileRankKey]; + } + + // Derivatives + const normalizedValue = _.get(row, `${metric.id}.normalized_value`, null); + + // Everything else + const value = _.get(row, `${metric.id}.value`, null); + return normalizedValue || value; + +}; diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/get_bucket_size.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_bucket_size.js new file mode 100644 index 00000000000000..d5d19f6a10278d --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_bucket_size.js @@ -0,0 +1,19 @@ +import calculateAuto from './calculate_auto'; +import moment from 'moment'; +import unitToSeconds from './unit_to_seconds'; +export default (req, interval) => { + const from = moment.utc(req.payload.timerange.min); + const to = moment.utc(req.payload.timerange.max); + const duration = moment.duration(to.valueOf() - from.valueOf(), 'ms'); + let bucketSize = calculateAuto.near(100, duration).asSeconds(); + if (bucketSize < 1) bucketSize = 1; // don't go too small + let intervalString = `${bucketSize}s`; + + const matches = interval && interval.match(/^([\d]+)([shmdwMy]|ms)$/); + if (matches) { + bucketSize = Number(matches[1]) * unitToSeconds(matches[2]); + intervalString = interval; + } + + return { bucketSize, intervalString }; +}; diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/get_buckets_path.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_buckets_path.js new file mode 100644 index 00000000000000..0430850a498ea7 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_buckets_path.js @@ -0,0 +1,31 @@ +import _ from 'lodash'; +export default (id, metrics) => { + const metric = _.find(metrics, { id }); + let bucketsPath = String(id); + + switch (metric.type) { + case 'derivative': + bucketsPath += '[normalized_value]'; + break; + case 'percentile': + const percentileKey = /\./.test(`${metric.percent}`) ? `${metric.percent}` : `${metric.percent}.0`; + bucketsPath += `[${percentileKey}]`; + break; + case 'percentile_rank': + bucketsPath += `[${metric.value}]`; + break; + case 'std_deviation': + case 'variance': + case 'sum_of_squares': + if (/^std_deviation/.test(metric.type) && ~['upper','lower'].indexOf(metric.mode)) { + bucketsPath += `[std_${metric.mode}]`; + } else { + bucketsPath += `[${metric.type}]`; + } + break; + } + + + return bucketsPath; +}; + diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/get_default_decoration.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_default_decoration.js new file mode 100644 index 00000000000000..29735f444a5e66 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_default_decoration.js @@ -0,0 +1,23 @@ +export default series => { + const pointSize = series.point_size != null ? Number(series.point_size) : Number(series.line_width); + const showPoints = series.chart_type === 'line' && pointSize !== 0; + return { + stack: series.stacked && series.stacked !== 'none' || false, + lines: { + show: series.chart_type === 'line' && series.line_width !== 0, + fill: Number(series.fill), + lineWidth: Number(series.line_width), + steps: series.steps || false + }, + points: { + show: showPoints, + radius: 1, + lineWidth: showPoints ? pointSize : 5 + }, + bars: { + show: series.chart_type === 'bar', + fill: Number(series.fill), + lineWidth: Number(series.line_width) + } + }; +}; diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/get_last_metric.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_last_metric.js new file mode 100644 index 00000000000000..ff88c6bad8a1b9 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_last_metric.js @@ -0,0 +1,6 @@ +import _ from 'lodash'; +export default function getLastMetric(series) { + return _.last(series.metrics.filter(s => s.type !== 'series_agg')); + +} + diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/get_sibling_agg_value.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_sibling_agg_value.js new file mode 100644 index 00000000000000..e12d69fca57160 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_sibling_agg_value.js @@ -0,0 +1,8 @@ +import _ from 'lodash'; +export default (row, metric) => { + let key = metric.type.replace(/_bucket$/, ''); + if (key === 'std_deviation' && _.includes(['upper', 'lower'], metric.mode)) { + key = `std_deviation_bounds.${metric.mode}`; + } + return _.get(row, `${metric.id}.${key}`); +}; diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/get_split_colors.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_split_colors.js new file mode 100644 index 00000000000000..40d92ec56d710a --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_split_colors.js @@ -0,0 +1,24 @@ +import Color from 'color'; +export default function getSplitColors(inputColor, size = 10, style = 'gradient') { + const color = new Color(inputColor); + const colors = []; + let workingColor = Color.hsl(color.hsl().object()); + + if (style === 'rainbow') { + return [ + '#68BC00', '#009CE0', '#B0BC00', '#16A5A5', '#D33115', '#E27300', '#FCC400','#7B64FF', '#FA28FF', '#333333', '#808080', + '#194D33', '#0062B1', '#808900', '#0C797D', '#9F0500', '#C45100', '#FB9E00','#653294', '#AB149E', '#0F1419', '#666666' + ]; + } else { + colors.push(color.hex()); + const rotateBy = (color.luminosity() / (size - 1)); + for(let i = 0; i < (size - 1); i++) { + const hsl = workingColor.hsl().object(); + hsl.l -= (rotateBy * 100); + workingColor = Color.hsl(hsl); + colors.push(workingColor.hex()); + } + } + + return colors; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/get_splits.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_splits.js new file mode 100644 index 00000000000000..25f3efc45a4187 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_splits.js @@ -0,0 +1,53 @@ +import Color from 'color'; +import calculateLabel from '../../../../public/components/lib/calculate_label'; +import _ from 'lodash'; +import getLastMetric from './get_last_metric'; +import getSplitColors from './get_split_colors'; +export default function getSplits(resp, series) { + const metric = getLastMetric(series); + if (_.has(resp, `aggregations.${series.id}.buckets`)) { + const buckets = _.get(resp, `aggregations.${series.id}.buckets`); + if (_.isArray(buckets)) { + const size = buckets.length; + const colors = getSplitColors(series.color, size, series.split_color_mode); + return buckets.map(bucket => { + bucket.id = `${series.id}:${bucket.key}`; + bucket.label = bucket.key; + bucket.color = colors.shift(); + return bucket; + }); + } + + if(series.split_mode === 'filters' && _.isPlainObject(buckets)) { + return series.split_filters.map(filter => { + const bucket = _.get(resp, `aggregations.${series.id}.buckets.${filter.id}`); + bucket.id = `${series.id}:${filter.id}`; + bucket.key = filter.id; + bucket.color = filter.color; + bucket.label = filter.label || filter.filter || '*'; + return bucket; + }); + } + } + + const color = new Color(series.color); + const timeseries = _.get(resp, `aggregations.${series.id}.timeseries`); + const mergeObj = { + timeseries + }; + series.metrics + .filter(m => /_bucket/.test(m.type)) + .forEach(m => { + mergeObj[m.id] = _.get(resp, `aggregations.${series.id}.${m.id}`); + }); + return [ + { + id: series.id, + label: series.label || calculateLabel(metric, series.metrics), + color: color.hex(), + ...mergeObj + } + ]; +} + + diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/get_timerange.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_timerange.js new file mode 100644 index 00000000000000..b35f3ecbec69be --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/get_timerange.js @@ -0,0 +1,6 @@ +import moment from 'moment'; +export default function getTimerange(req) { + const from = moment.utc(req.payload.timerange.min); + const to = moment.utc(req.payload.timerange.max); + return { from, to }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/index.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/index.js new file mode 100644 index 00000000000000..3e60224de501d6 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/index.js @@ -0,0 +1,27 @@ +import bucketTransform from './bucket_transform'; +import getAggValue from './get_agg_value'; +import getBucketSize from './get_bucket_size'; +import getBucketPath from './get_buckets_path'; +import getDefaultDecoration from './get_default_decoration'; +import getLastMetric from './get_last_metric'; +import getSiblingAggValue from './get_sibling_agg_value'; +import getSplits from './get_splits'; +import getTimerange from './get_timerange'; +import mapBucket from './map_bucket'; +import parseSettings from './parse_settings'; +import unitToSeconds from './unit_to_seconds'; + +module.exports = { + bucketTransform, + getAggValue, + getBucketSize, + getBucketPath, + getDefaultDecoration, + getLastMetric, + getSiblingAggValue, + getSplits, + getTimerange, + mapBucket, + parseSettings, + unitToSeconds, +}; diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/map_bucket.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/map_bucket.js new file mode 100644 index 00000000000000..6fd7b6b2bdd012 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/map_bucket.js @@ -0,0 +1,4 @@ +import getAggValue from './get_agg_value'; +export default function mapBucket(metric) { + return bucket => [ bucket.key, getAggValue(bucket, metric)]; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/parse_settings.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/parse_settings.js new file mode 100644 index 00000000000000..cca30934f9fafe --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/parse_settings.js @@ -0,0 +1,29 @@ +const numericKeys = [ + 'alpha', + 'beta', + 'gamma', + 'period' +]; +const booleanKeys = [ 'pad' ]; +function castBasedOnKey(key, val) { + if (~numericKeys.indexOf(key)) return Number(val); + if (~booleanKeys.indexOf(key)) { + switch(val) { + case 'true': + case 1: + case '1': + return true; + default: + return false; + } + } + return val; +} +export default (settingsStr) => { + return settingsStr.split(/\s/).reduce((acc, value) => { + const [key, val] = value.split(/=/); + acc[key] = castBasedOnKey(key, val); + return acc; + }, {}); +}; + diff --git a/src/core_plugins/metrics/server/lib/vis_data/helpers/unit_to_seconds.js b/src/core_plugins/metrics/server/lib/vis_data/helpers/unit_to_seconds.js new file mode 100644 index 00000000000000..580aad0bdd3fd3 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/helpers/unit_to_seconds.js @@ -0,0 +1,14 @@ +const units = { + ms: 0.001, + s: 1, + m: 60, + h: 3600, + d: 86400, + w: (86400) * 7, // Hum... might be wrong + M: (86400) * 30, // this too... 29,30,31? + y: (86400) * 356 // Leap year? +}; + +export default (unit) => { + return units[unit]; +}; diff --git a/src/core_plugins/metrics/server/lib/vis_data/offset_time.js b/src/core_plugins/metrics/server/lib/vis_data/offset_time.js new file mode 100644 index 00000000000000..6ec7618f3557e0 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/offset_time.js @@ -0,0 +1,12 @@ +import getTimerange from './helpers/get_timerange'; +export default function offsetTime(req, by) { + const { from, to } = getTimerange(req); + if (!/^([\d]+)([shmdwMy]|ms)$/.test(by)) return { from, to }; + const matches = by.match(/^([\d]+)([shmdwMy]|ms)$/); + const offsetValue = Number(matches[1]); + const offsetUnit = matches[2]; + return { + from: from.clone().subtract(offsetValue, offsetUnit), + to: to.clone().subtract(offsetValue, offsetUnit) + }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/date_histogram.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/date_histogram.js new file mode 100644 index 00000000000000..dba54333489ab2 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/date_histogram.js @@ -0,0 +1,22 @@ +import _ from 'lodash'; +import moment from 'moment'; +import getBucketSize from '../../helpers/get_bucket_size'; +import getTimerange from '../../helpers/get_timerange'; +export default function dateHistogram(req, panel, annotation) { + return next => doc => { + const timeField = annotation.time_field; + const { bucketSize, intervalString } = getBucketSize(req, 'auto'); + const { from, to } = getTimerange(req); + _.set(doc, `aggs.${annotation.id}.date_histogram`, { + field: timeField, + interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: from.valueOf(), + max: to.valueOf() - (bucketSize * 1000) + } + }); + return next(doc); + }; +} + diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/index.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/index.js new file mode 100644 index 00000000000000..fa9656c1c02f83 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/index.js @@ -0,0 +1,8 @@ +import query from './query'; +import dateHistogram from './date_histogram'; +import topHits from './top_hits'; +export default [ + query, + dateHistogram, + topHits +]; diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/query.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/query.js new file mode 100644 index 00000000000000..72b286dba05a72 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/query.js @@ -0,0 +1,49 @@ +import _ from 'lodash'; +import moment from 'moment'; +import getBucketSize from '../../helpers/get_bucket_size'; +import getTimerange from '../../helpers/get_timerange'; +export default function query(req, panel, annotation) { + return next => doc => { + const timeField = annotation.time_field; + const { bucketSize, intervalString } = getBucketSize(req, 'auto'); + const { from, to } = getTimerange(req); + + doc.size = 0; + doc.query = { + bool: { + must: [] + } + }; + + const timerange = { + range: { + [timeField]: { + gte: from.valueOf(), + lte: to.valueOf() - (bucketSize * 1000), + format: 'epoch_millis', + } + } + }; + doc.query.bool.must.push(timerange); + + if (annotation.query_string) { + doc.query.bool.must.push({ + query_string: { + query: annotation.query_string, + analyze_wildcard: true + } + }); + } + + if (annotation.fields) { + const fields = annotation.fields.split(/[,\s]+/) || []; + fields.forEach(field => { + doc.query.bool.must.push({ exists: { field } }); + }); + } + + return next(doc); + + }; +} + diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/top_hits.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/top_hits.js new file mode 100644 index 00000000000000..22cb7796029656 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/annotations/top_hits.js @@ -0,0 +1,22 @@ +import _ from 'lodash'; +export default function topHits(req, panel, annotation) { + return next => doc => { + const fields = annotation.fields && annotation.fields.split(/[,\s]+/) || []; + const timeField = annotation.time_field; + _.set(doc, `aggs.${annotation.id}.aggs.hits.top_hits`, { + sort: [ + { + [timeField]: { order: 'desc' } + } + ], + _source: { + includes: [ + ...fields, + timeField + ] + }, + size: 5 + }); + return next(doc); + }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/date_histogram.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/date_histogram.js new file mode 100644 index 00000000000000..ccf38744f04587 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/date_histogram.js @@ -0,0 +1,110 @@ +import dateHistogram from '../date_histogram'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('dateHistogram(req, panel, series)', () => { + + let panel; + let series; + let req; + beforeEach(() => { + req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z' + } + } + }; + panel = { + index_pattern: '*', + time_field: '@timestamp', + interval: '10s' + }; + series = { id: 'test' }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + dateHistogram(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + }); + + it('returns valid date histogram', () => { + const next = doc => doc; + const doc = dateHistogram(req, panel, series)(next)({}); + expect(doc).to.eql({ + aggs: { + test: { + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + interval: '10s', + min_doc_count: 0, + extended_bounds: { + min: 1483228800000, + max: 1483232390000 + } + } + } + } + } + } + }); + }); + + it('returns valid date histogram (offset by 1h)', () => { + series.offset_time = '1h'; + const next = doc => doc; + const doc = dateHistogram(req, panel, series)(next)({}); + expect(doc).to.eql({ + aggs: { + test: { + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + interval: '10s', + min_doc_count: 0, + extended_bounds: { + min: 1483225200000, + max: 1483228790000 + } + } + } + } + } + } + }); + }); + + it('returns valid date histogram with overriden index pattern', () => { + series.override_index_pattern = 1; + series.series_index_pattern = '*'; + series.series_time_field = 'timestamp'; + series.series_interval = '20s'; + const next = doc => doc; + const doc = dateHistogram(req, panel, series)(next)({}); + expect(doc).to.eql({ + aggs: { + test: { + aggs: { + timeseries: { + date_histogram: { + field: 'timestamp', + interval: '20s', + min_doc_count: 0, + extended_bounds: { + min: 1483228800000, + max: 1483232380000 + } + } + } + } + } + } + }); + }); + +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/metric_buckets.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/metric_buckets.js new file mode 100644 index 00000000000000..72f08ed4e5aa5c --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/metric_buckets.js @@ -0,0 +1,84 @@ +import metricBuckets from '../metric_buckets'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('metricBuckets(req, panel, series)', () => { + + let panel; + let series; + let req; + beforeEach(() => { + panel = { + time_field: 'timestamp' + }; + series = { + id: 'test', + split_mode: 'terms', + terms_size: 10, + terms_field: 'host', + metrics: [ + { + id: 'metric-1', + type: 'max', + field: 'io' + }, + { + id: 'metric-2', + type: 'derivative', + field: 'metric-1', + unit: '1s' + }, + { + id: 'metric-3', + type: 'avg_bucket', + field: 'metric-2' + } + ] + }; + req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z' + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + metricBuckets(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + }); + + it('returns metric aggs', () => { + const next = doc => doc; + const doc = metricBuckets(req, panel, series)(next)({}); + expect(doc).to.eql({ + aggs: { + test: { + aggs: { + timeseries: { + aggs: { + 'metric-1': { + max: { + field: 'io' + } + }, + 'metric-2': { + derivative: { + buckets_path: 'metric-1', + gap_policy: 'skip', + unit: '1s' + } + } + } + } + } + } + } + }); + + }); +}); + diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/query.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/query.js new file mode 100644 index 00000000000000..cb3e93a3c64466 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/query.js @@ -0,0 +1,227 @@ +import query from '../query'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('query(req, panel, series)', () => { + + let panel; + let series; + let req; + beforeEach(() => { + req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z' + } + } + }; + panel = { + index_pattern: '*', + time_field: 'timestamp', + interval: '10s' + }; + series = { id: 'test' }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + query(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + }); + + it('returns doc with query for timerange', () => { + const next = doc => doc; + const doc = query(req, panel, series)(next)({}); + expect(doc).to.eql({ + size: 0, + query: { + bool: { + must: [ + { + range: { + timestamp: { + gte: 1483228800000, + lte: 1483232390000, + format: 'epoch_millis' + } + } + } + ] + } + } + }); + }); + + it('returns doc with query for timerange (offset by 1h)', () => { + series.offset_time = '1h'; + const next = doc => doc; + const doc = query(req, panel, series)(next)({}); + expect(doc).to.eql({ + size: 0, + query: { + bool: { + must: [ + { + range: { + timestamp: { + gte: 1483225200000, + lte: 1483228790000, + format: 'epoch_millis' + } + } + } + ] + } + } + }); + }); + + it('returns doc with global query', () => { + req.payload.filters = [ + { + bool: { + must: [ + { + term: { + host: 'example' + } + } + ] + } + } + ]; + const next = doc => doc; + const doc = query(req, panel, series)(next)({}); + expect(doc).to.eql({ + size: 0, + query: { + bool: { + must: [ + { + range: { + timestamp: { + gte: 1483228800000, + lte: 1483232390000, + format: 'epoch_millis' + } + } + }, + { + bool: { + must: [ + { + term: { + host: 'example' + } + } + ] + } + } + ] + } + } + }); + }); + + it('returns doc with panel filter and global', () => { + req.payload.filters = [ + { + bool: { + must: [ + { + term: { + host: 'example' + } + } + ] + } + } + ]; + panel.filter = 'host:web-server'; + const next = doc => doc; + const doc = query(req, panel, series)(next)({}); + expect(doc).to.eql({ + size: 0, + query: { + bool: { + must: [ + { + range: { + timestamp: { + gte: 1483228800000, + lte: 1483232390000, + format: 'epoch_millis' + } + } + }, + { + bool: { + must: [ + { + term: { + host: 'example' + } + } + ] + } + }, + { + query_string: { + query: panel.filter, + analyze_wildcard: true + } + } + ] + } + } + }); + }); + + it('returns doc with panel filter (ignoring globals)', () => { + req.payload.filters = [ + { + bool: { + must: [ + { + term: { + host: 'example' + } + } + ] + } + } + ]; + panel.filter = 'host:web-server'; + panel.ignore_global_filter = true; + const next = doc => doc; + const doc = query(req, panel, series)(next)({}); + expect(doc).to.eql({ + size: 0, + query: { + bool: { + must: [ + { + range: { + timestamp: { + gte: 1483228800000, + lte: 1483232390000, + format: 'epoch_millis' + } + } + }, + { + query_string: { + query: panel.filter, + analyze_wildcard: true + } + } + ] + } + } + }); + }); + + +}); + diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/sibling_buckets.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/sibling_buckets.js new file mode 100644 index 00000000000000..c4a531e0e40745 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/sibling_buckets.js @@ -0,0 +1,68 @@ +import siblingBuckets from '../sibling_buckets'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('siblingBuckets(req, panel, series)', () => { + + let panel; + let series; + let req; + beforeEach(() => { + panel = { + time_field: 'timestamp' + }; + series = { + id: 'test', + split_mode: 'terms', + terms_size: 10, + terms_field: 'host', + metrics: [ + { + id: 'metric-1', + type: 'avg', + field: 'cpu' + }, + { + id: 'metric-2', + type: 'avg_bucket', + field: 'metric-1' + } + ] + }; + req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z' + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + siblingBuckets(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + }); + + it('returns sibling aggs', () => { + const next = doc => doc; + const doc = siblingBuckets(req, panel, series)(next)({}); + expect(doc).to.eql({ + aggs: { + test: { + aggs: { + 'metric-2': { + extended_stats_bucket: { + buckets_path: 'timeseries > metric-1' + } + } + } + } + } + }); + + }); +}); + + diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_everything.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_everything.js new file mode 100644 index 00000000000000..cc2ad67784f966 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_everything.js @@ -0,0 +1,52 @@ +import splitByEverything from '../split_by_everything'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('splitByEverything(req, panel, series)', () => { + + let panel; + let series; + let req; + beforeEach(() => { + panel = {}; + series = { id: 'test', split_mode: 'everything' }; + req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z' + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + splitByEverything(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + }); + + it('returns a valid filter with match_all', () => { + const next = doc => doc; + const doc = splitByEverything(req, panel, series)(next)({}); + expect(doc).to.eql({ + aggs: { + test: { + filter: { + match_all: {} + } + } + } + }); + }); + + it('calls next and does not add a filter', () => { + series.split_mode = 'terms'; + series.terms_field = 'host'; + const next = sinon.spy(doc => doc); + const doc = splitByEverything(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + expect(doc).to.eql({}); + }); + +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_filter.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_filter.js new file mode 100644 index 00000000000000..fbabb0f9c26fd2 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_filter.js @@ -0,0 +1,55 @@ +import splitByFilter from '../split_by_filter'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('splitByFilter(req, panel, series)', () => { + + let panel; + let series; + let req; + beforeEach(() => { + panel = {}; + series = { id: 'test', split_mode: 'filter', filter: 'host:example-01' }; + req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z' + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + splitByFilter(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + }); + + it('returns a valid filter with a query_string', () => { + const next = doc => doc; + const doc = splitByFilter(req, panel, series)(next)({}); + expect(doc).to.eql({ + aggs: { + test: { + filter: { + query_string: { + query: 'host:example-01', + analyze_wildcard: true + } + } + } + } + }); + }); + + it('calls next and does not add a filter', () => { + series.split_mode = 'terms'; + const next = sinon.spy(doc => doc); + const doc = splitByFilter(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + expect(doc).to.eql({}); + }); + +}); + diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_filters.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_filters.js new file mode 100644 index 00000000000000..dfa20e8facc16e --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_filters.js @@ -0,0 +1,88 @@ +import splitByFilters from '../split_by_filters'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('splitByFilters(req, panel, series)', () => { + + let panel; + let series; + let req; + beforeEach(() => { + panel = { + time_field: 'timestamp' + }; + series = { + id: 'test', + split_mode: 'filters', + split_filters: [ + { + id: 'filter-1', + color: '#F00', + filter: 'status_code:[* TO 200]', + label: '200s' + }, + { + id: 'filter-2', + color: '#0F0', + filter: 'status_code:[300 TO *]', + label: '300s' + } + + ], + metrics: [{ id: 'avgmetric', type: 'avg', field: 'cpu' }] + }; + req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z' + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + splitByFilters(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + }); + + it('returns a valid terms agg', () => { + const next = doc => doc; + const doc = splitByFilters(req, panel, series)(next)({}); + expect(doc).to.eql({ + aggs: { + test: { + filters: { + filters: { + 'filter-1': { + query_string: { + query: 'status_code:[* TO 200]', + analyze_wildcard: true + } + }, + 'filter-2': { + query_string: { + query: 'status_code:[300 TO *]', + analyze_wildcard: true + } + } + } + } + } + } + }); + }); + + it('calls next and does not add a terms agg', () => { + series.split_mode = 'everything'; + const next = sinon.spy(doc => doc); + const doc = splitByFilters(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + expect(doc).to.eql({}); + }); + +}); + + + diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_terms.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_terms.js new file mode 100644 index 00000000000000..d057e2456f9ec0 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/__tests__/split_by_terms.js @@ -0,0 +1,101 @@ +import splitByTerms from '../split_by_terms'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('splitByTerms(req, panel, series)', () => { + + let panel; + let series; + let req; + beforeEach(() => { + panel = { + time_field: 'timestamp' + }; + series = { + id: 'test', + split_mode: 'terms', + terms_size: 10, + terms_field: 'host', + metrics: [{ id: 'avgmetric', type: 'avg', field: 'cpu' }] + }; + req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z' + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + splitByTerms(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + }); + + it('returns a valid terms agg', () => { + const next = doc => doc; + const doc = splitByTerms(req, panel, series)(next)({}); + expect(doc).to.eql({ + aggs: { + test: { + terms: { + field: 'host', + size: 10 + } + } + } + }); + }); + + it('returns a valid terms agg with custom sort', () => { + series.terms_order_by = 'avgmetric'; + const next = doc => doc; + const doc = splitByTerms(req, panel, series)(next)({}); + expect(doc).to.eql({ + aggs: { + test: { + terms: { + field: 'host', + size: 10, + order: { + 'avgmetric-SORT > SORT': 'desc' + } + }, + aggs: { + 'avgmetric-SORT': { + aggs: { + SORT: { + avg: { + field: 'cpu' + } + } + }, + filter: { + range: { + timestamp: { + format: 'epoch_millis', + gte: 1483232355000, + lte: 1483232400000 + } + } + } + } + } + } + } + }); + }); + + it('calls next and does not add a terms agg', () => { + series.split_mode = 'everything'; + const next = sinon.spy(doc => doc); + const doc = splitByTerms(req, panel, series)(next)({}); + expect(next.calledOnce).to.equal(true); + expect(doc).to.eql({}); + }); + +}); + + diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/date_histogram.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/date_histogram.js new file mode 100644 index 00000000000000..bf99dbeaf16871 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/date_histogram.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import getBucketSize from '../../helpers/get_bucket_size'; +import offsetTime from '../../offset_time'; +import getIntervalAndTimefield from '../../get_interval_and_timefield'; +export default function dateHistogram(req, panel, series) { + return next => doc => { + const { timeField, interval } = getIntervalAndTimefield(panel, series); + const { bucketSize, intervalString } = getBucketSize(req, interval); + const { from, to } = offsetTime(req, series.offset_time); + _.set(doc, `aggs.${series.id}.aggs.timeseries.date_histogram`, { + field: timeField, + interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: from.valueOf(), + max: to.valueOf() - (bucketSize * 1000) + } + }); + return next(doc); + }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/filter_ratios.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/filter_ratios.js new file mode 100644 index 00000000000000..f47a0ea97016c1 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/filter_ratios.js @@ -0,0 +1,45 @@ +/* eslint max-len:0 */ +const filter = metric => metric.type === 'filter_ratio'; +import bucketTransform from '../../helpers/bucket_transform'; +import _ from 'lodash'; +export default function ratios(req, panel, series) { + return next => doc => { + if (series.metrics.some(filter)) { + series.metrics.filter(filter).forEach(metric => { + _.set(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-numerator.filter`, { + query_string: { query: metric.numerator || '*', analyze_wildcard: true } + }); + _.set(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-denominator.filter`, { + query_string: { query: metric.denominator || '*', analyze_wildcard: true } + }); + + let numeratorPath = `${metric.id}-numerator>_count`; + let denominatorPath = `${metric.id}-denominator>_count`; + + if (metric.metric_agg !== 'count' && bucketTransform[metric.metric_agg]) { + const aggBody = { + metric: bucketTransform[metric.metric_agg]({ + type: metric.metric_agg, + field: metric.field + }) + }; + _.set(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-numerator.aggs`, aggBody); + _.set(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-denominator.aggs`, aggBody); + numeratorPath = `${metric.id}-numerator>metric`; + denominatorPath = `${metric.id}-denominator>metric`; + } + + _.set(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}`, { + bucket_script: { + buckets_path: { + numerator: numeratorPath, + denominator: denominatorPath + }, + script: 'params.numerator != null && params.denominator != null && params.denominator > 0 ? params.numerator / params.denominator : 0' + } + }); + }); + } + return doc; + }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/index.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/index.js new file mode 100644 index 00000000000000..a397978775ea95 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/index.js @@ -0,0 +1,21 @@ +import query from './query'; +import splitByEverything from './split_by_everything'; +import splitByFilter from './split_by_filter'; +import splitByFilters from './split_by_filters'; +import splitByTerms from './split_by_terms'; +import dateHistogram from './date_histogram'; +import metricBuckets from './metric_buckets'; +import siblingBuckets from './sibling_buckets'; +import filterRatios from './filter_ratios'; + +export default [ + query, + splitByTerms, + splitByFilter, + splitByFilters, + splitByEverything, + dateHistogram, + metricBuckets, + siblingBuckets, + filterRatios +]; diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/metric_buckets.js new file mode 100644 index 00000000000000..6afc6dd11b9a3c --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -0,0 +1,24 @@ +import _ from 'lodash'; +import getBucketSize from '../../helpers/get_bucket_size'; +import bucketTransform from '../../helpers/bucket_transform'; +import getIntervalAndTimefield from '../../get_interval_and_timefield'; +export default function metricBuckets(req, panel, series) { + return next => doc => { + const { timeField, interval } = getIntervalAndTimefield(panel, series); + const { bucketSize, intervalString } = getBucketSize(req, interval); + series.metrics + .filter(row => !/_bucket$/.test(row.type) && !/^series/.test(row.type)) + .forEach(metric => { + const fn = bucketTransform[metric.type]; + if (fn) { + try { + const bucket = fn(metric, series.metrics, intervalString); + _.set(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}`, bucket); + } catch (e) { + // meh + } + } + }); + return next(doc); + }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/query.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/query.js new file mode 100644 index 00000000000000..89ae536ed87743 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/query.js @@ -0,0 +1,48 @@ +import _ from 'lodash'; +import moment from 'moment'; +import getBucketSize from '../../helpers/get_bucket_size'; +import unitToSeconds from '../../helpers/unit_to_seconds'; +import offsetTime from '../../offset_time'; +import getIntervalAndTimefield from '../../get_interval_and_timefield'; +export default function query(req, panel, series) { + return next => doc => { + const { timeField, interval } = getIntervalAndTimefield(panel, series); + const { bucketSize, intervalString } = getBucketSize(req, interval); + const { from, to } = offsetTime(req, series.offset_time); + + doc.size = 0; + doc.query = { + bool: { + must: [] + } + }; + + const timerange = { + range: { + [timeField]: { + gte: from.valueOf(), + lte: to.valueOf() - (bucketSize * 1000), + format: 'epoch_millis', + } + } + }; + doc.query.bool.must.push(timerange); + + const globalFilters = req.payload.filters; + if (globalFilters && !panel.ignore_global_filter) { + doc.query.bool.must = doc.query.bool.must.concat(globalFilters); + } + + if (panel.filter) { + doc.query.bool.must.push({ + query_string: { + query: panel.filter, + analyze_wildcard: true + } + }); + } + + return next(doc); + + }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/sibling_buckets.js new file mode 100644 index 00000000000000..d9082265486c80 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -0,0 +1,24 @@ +import _ from 'lodash'; +import getBucketSize from '../../helpers/get_bucket_size'; +import bucketTransform from '../../helpers/bucket_transform'; +import getIntervalAndTimefield from '../../get_interval_and_timefield'; +export default function siblingBuckets(req, panel, series) { + return next => doc => { + const { timeField, interval } = getIntervalAndTimefield(panel, series); + const { bucketSize, intervalString } = getBucketSize(req, interval); + series.metrics + .filter(row => /_bucket$/.test(row.type)) + .forEach(metric => { + const fn = bucketTransform[metric.type]; + if (fn) { + try { + const bucket = fn(metric, series.metrics, bucketSize); + _.set(doc, `aggs.${series.id}.aggs.${metric.id}`, bucket); + } catch (e) { + // meh + } + } + }); + return next(doc); + }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_everything.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_everything.js new file mode 100644 index 00000000000000..a2bbe17901bef0 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_everything.js @@ -0,0 +1,12 @@ +import _ from 'lodash'; +export default function splitByEverything(req, panel, series) { + return next => doc => { + if (series.split_mode === 'everything' || + (series.split_mode === 'terms' && + !series.terms_field)) { + _.set(doc, `aggs.${series.id}.filter.match_all`, {}); + } + return next(doc); + }; +} + diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_filter.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_filter.js new file mode 100644 index 00000000000000..eeaf1f6c8d34a9 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_filter.js @@ -0,0 +1,9 @@ +import _ from 'lodash'; +export default function splitByFilter(req, panel, series) { + return next => doc => { + if (series.split_mode !== 'filter') return next(doc); + _.set(doc, `aggs.${series.id}.filter.query_string.query`, series.filter || '*'); + _.set(doc, `aggs.${series.id}.filter.query_string.analyze_wildcard`, true); + return next(doc); + }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_filters.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_filters.js new file mode 100644 index 00000000000000..590046fde9a536 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_filters.js @@ -0,0 +1,13 @@ +import _ from 'lodash'; +export default function splitByFilter(req, panel, series) { + return next => doc => { + if (series.split_mode === 'filters' && series.split_filters) { + series.split_filters.forEach(filter => { + _.set(doc, `aggs.${series.id}.filters.filters.${filter.id}.query_string.query`, filter.filter || '*'); + _.set(doc, `aggs.${series.id}.filters.filters.${filter.id}.query_string.analyze_wildcard`, true); + }); + } + return next(doc); + }; +} + diff --git a/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_terms.js b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_terms.js new file mode 100644 index 00000000000000..bf5540ae8e7582 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/request_processors/series/split_by_terms.js @@ -0,0 +1,45 @@ +import _ from 'lodash'; +import moment from 'moment'; +import basicAggs from '../../../../../public/components/lib/basic_aggs'; +import getBucketSize from '../../helpers/get_bucket_size'; +import getTimerange from '../../helpers/get_timerange'; +import getIntervalAndTimefield from '../../get_interval_and_timefield'; +import getBucketsPath from '../../helpers/get_buckets_path'; +import bucketTransform from '../../helpers/bucket_transform'; + +export default function splitByTerm(req, panel, series) { + return next => doc => { + if (series.split_mode === 'terms' && series.terms_field) { + const { timeField, interval } = getIntervalAndTimefield(panel, series); + const { bucketSize, intervalString } = getBucketSize(req, interval); + const { from, to } = getTimerange(req); + + _.set(doc, `aggs.${series.id}.terms.field`, series.terms_field); + _.set(doc, `aggs.${series.id}.terms.size`, series.terms_size); + const metric = series.metrics.find(item => item.id === series.terms_order_by); + if (metric && metric.type !== 'count' && ~basicAggs.indexOf(metric.type)) { + const sortAggKey = `${series.terms_order_by}-SORT`; + const fn = bucketTransform[metric.type]; + const bucketPath = getBucketsPath(series.terms_order_by, series.metrics) + .replace(series.terms_order_by, `${sortAggKey} > SORT`); + _.set(doc, `aggs.${series.id}.terms.order`, { [bucketPath]: 'desc' }); + _.set(doc, `aggs.${series.id}.aggs`, { + [sortAggKey]: { + filter: { + range: { + [timeField]: { + gte: to.valueOf() - (bucketSize * 1500), + lte: to.valueOf(), + format: 'epoch_millis' + } + } + }, + aggs: { SORT: fn(metric) } + } + }); + } + } + return next(doc); + }; +} + diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/_series_agg.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/_series_agg.js new file mode 100644 index 00000000000000..d089f770c6574c --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/_series_agg.js @@ -0,0 +1,73 @@ +import { expect } from 'chai'; +import seriesAgg from '../_series_agg'; + +describe('seriesAgg', () => { + const series = [ + [[0,2],[1,1],[2,3]], + [[0,4],[1,2],[2,3]], + [[0,2],[1,1],[2,3]] + ]; + + describe('basic', () => { + it('returns the series sum', () => { + expect(seriesAgg.sum(series)).to.eql([ + [[0,8], [1,4], [2,9]] + ]); + }); + + it('returns the series max', () => { + expect(seriesAgg.max(series)).to.eql([ + [[0,4], [1,2], [2,3]] + ]); + }); + + it('returns the series min', () => { + expect(seriesAgg.min(series)).to.eql([ + [[0,2], [1,1], [2,3]] + ]); + }); + + it('returns the series mean', () => { + expect(seriesAgg.mean(series)).to.eql([ + [[0,(8 / 3)], [1,(4 / 3)], [2,3]] + ]); + }); + }); + + describe('overall', () => { + it('returns the series overall sum', () => { + expect(seriesAgg.overall_sum(series)).to.eql([ + [[0,21], [1,21], [2,21]] + ]); + }); + + it('returns the series overall max', () => { + expect(seriesAgg.overall_max(series)).to.eql([ + [[0,4], [1,4], [2,4]] + ]); + }); + + it('returns the series overall min', () => { + expect(seriesAgg.overall_min(series)).to.eql([ + [[0,1], [1,1], [2,1]] + ]); + }); + + it('returns the series overall mean', () => { + const value = ((8) + (4) + 9) / 3; + expect(seriesAgg.overall_avg(series)).to.eql([ + [[0,value], [1,value], [2,value]] + ]); + }); + + }); + + describe('cumlative sum', () => { + it('returns the series cumlative sum', () => { + expect(seriesAgg.cumlative_sum(series)).to.eql([ + [[0,8], [1,12], [2,21]] + ]); + }); + }); + +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/percentile.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/percentile.js new file mode 100644 index 00000000000000..2be59fa7409e9d --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/percentile.js @@ -0,0 +1,138 @@ +import percentile from '../percentile'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('percentile(resp, panel, series)', () => { + let panel; + let series; + let resp; + beforeEach(() => { + panel = { + time_field: 'timestamp' + }; + series = { + chart_type: 'line', + stacked: false, + line_width: 1, + point_size: 1, + fill: 0, + color: '#F00', + id: 'test', + split_mode: 'everything', + metrics: [{ + id: 'pct', + type: 'percentile', + field: 'cpu', + percentiles: [ + { id: '10-90', mode: 'band', value: 10, percentile: 90, shade: 0.2 }, + { id: '50', mode: 'line', value: 50 } + ] + }] + }; + resp = { + aggregations: { + test: { + timeseries: { + buckets: [ + { + key: 1, + pct: { + values: { + '10.0': 1, + '50.0': 2.5, + '90.0': 5 + } + } + }, + { + key: 2, + pct: { + values: { + '10.0': 1.2, + '50.0': 2.7, + '90.0': 5.3 + } + } + } + ] + } + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + percentile(resp, panel, series)(next)([]); + expect(next.calledOnce).to.equal(true); + }); + + it('creates a series', () => { + const next = results => results; + const results = percentile(resp, panel, series)(next)([]); + expect(results).to.have.length(3); + + expect(results[0]).to.have.property('id', '10-90:test'); + expect(results[0]).to.have.property('color', '#FF0000'); + expect(results[0]).to.have.property('fillBetween', '10-90:test:90'); + expect(results[0]).to.have.property('label', 'Percentile of cpu (10)'); + expect(results[0]).to.have.property('legend', false); + expect(results[0]).to.have.property('lines'); + expect(results[0].lines).to.eql({ + fill: 0.2, + lineWidth: 0, + show: true, + }); + expect(results[0]).to.have.property('points'); + expect(results[0].points).to.eql({ show: false }); + expect(results[0].data).to.eql([ + [1,1], + [2,1.2] + ]); + + expect(results[1]).to.have.property('id', '10-90:test:90'); + expect(results[1]).to.have.property('color', '#FF0000'); + expect(results[1]).to.have.property('label', 'Percentile of cpu (10)'); + expect(results[1]).to.have.property('legend', false); + expect(results[1]).to.have.property('lines'); + expect(results[1].lines).to.eql({ + fill: false, + lineWidth: 0, + show: true, + }); + expect(results[1]).to.have.property('points'); + expect(results[1].points).to.eql({ show: false }); + expect(results[1].data).to.eql([ + [1,5], + [2,5.3] + ]); + + expect(results[2]).to.have.property('id', '50:test'); + expect(results[2]).to.have.property('color', '#FF0000'); + expect(results[2]).to.have.property('label', 'Percentile of cpu (50)'); + expect(results[2]).to.have.property('stack', false); + expect(results[2]).to.have.property('lines'); + expect(results[2].lines).to.eql({ + fill: 0, + lineWidth: 1, + show: true, + steps: false + }); + expect(results[2]).to.have.property('bars'); + expect(results[2].bars).to.eql({ + fill: 0, + lineWidth: 1, + show: false + }); + expect(results[2]).to.have.property('points'); + expect(results[2].points).to.eql({ show: true, lineWidth: 1, radius: 1 }); + expect(results[2].data).to.eql([ + [1,2.5], + [2,2.7] + ]); + + + }); + +}); + diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/series_agg.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/series_agg.js new file mode 100644 index 00000000000000..169f05c8cb838c --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/series_agg.js @@ -0,0 +1,109 @@ +import seriesAgg from '../series_agg'; +import stdMetric from '../std_metric'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('seriesAgg(resp, panel, series)', () => { + let panel; + let series; + let resp; + beforeEach(() => { + panel = { + time_field: 'timestamp' + }; + series = { + chart_type: 'line', + stacked: false, + line_width: 1, + point_size: 1, + fill: 0, + color: '#F00', + id: 'test', + label: 'Total CPU', + split_mode: 'terms', + metrics: [ + { + id: 'avgcpu', + type: 'avg', + field: 'cpu' + }, + { + id: 'seriesgg', + type: 'series_agg', + function: 'sum' + } + ] + }; + resp = { + aggregations: { + test: { + buckets: [ + { + key: 'example-01', + timeseries: { + buckets: [ + { + key: 1, + avgcpu: { value: 0.25 } + }, + { + key: 2, + avgcpu: { value: 0.25 } + } + ] + } + }, + { + key: 'example-02', + timeseries: { + buckets: [ + { + key: 1, + avgcpu: { value: 0.25 } + }, + { + key: 2, + avgcpu: { value: 0.25 } + } + ] + } + } + ] + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + seriesAgg(resp, panel, series)(next)([]); + expect(next.calledOnce).to.equal(true); + }); + + it('creates a series', () => { + const next = seriesAgg(resp, panel, series)(results => results); + const results = stdMetric(resp, panel, series)(next)([]); + expect(results).to.have.length(1); + + expect(results[0]).to.eql({ + id: 'test', + color: '#F00', + label: 'Total CPU', + stack: false, + lines: { show: true, fill: 0, lineWidth: 1, steps: false }, + points: { show: true, radius: 1, lineWidth: 1 }, + bars: { fill: 0, lineWidth: 1, show: false }, + data: [ + [ 1, 0.5 ], + [ 2, 0.5 ] + ] + }); + + }); + +}); + + + + + diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_deviation_bands.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_deviation_bands.js new file mode 100644 index 00000000000000..f9eb70c4269e0a --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_deviation_bands.js @@ -0,0 +1,100 @@ +import stdDeviationBands from '../std_deviation_bands'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('stdDeviationBands(resp, panel, series)', () => { + let panel; + let series; + let resp; + beforeEach(() => { + panel = { + time_field: 'timestamp' + }; + series = { + chart_type: 'line', + stacked: false, + line_width: 1, + point_size: 1, + fill: 0, + color: '#F00', + id: 'test', + split_mode: 'everything', + metrics: [{ + id: 'stddev', + mode: 'band', + type: 'std_deviation', + field: 'cpu' + }] + }; + resp = { + aggregations: { + test: { + timeseries: { + buckets: [ + { + key: 1, + stddev: { + std_deviation: 1.2, + std_deviation_bounds: { + upper: 3.2, + lower: 0.2 + } + } + }, + { + key: 2, + stddev: { + std_deviation_bands: 1.5, + std_deviation_bounds: { + upper: 3.5, + lower: 0.5 + } + } + } + ] + } + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + stdDeviationBands(resp, panel, series)(next)([]); + expect(next.calledOnce).to.equal(true); + }); + + it('creates a series', () => { + const next = results => results; + const results = stdDeviationBands(resp, panel, series)(next)([]); + expect(results).to.have.length(2); + + expect(results[0]).to.eql({ + id: 'test:upper', + label: 'Std. Deviation of cpu', + color: '#FF0000', + lines: { show: true, fill: 0.5, lineWidth: 0 }, + points: { show: false }, + fillBetween: 'test:lower', + data: [ + [ 1, 3.2 ], + [ 2, 3.5 ] + ] + }); + + expect(results[1]).to.eql({ + id: 'test:lower', + color: '#FF0000', + lines: { show: true, fill: false, lineWidth: 0 }, + points: { show: false }, + data: [ + [ 1, 0.2 ], + [ 2, 0.5 ] + ] + }); + + }); + +}); + + diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_deviation_sibling.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_deviation_sibling.js new file mode 100644 index 00000000000000..e9b5d9ff4ca88a --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_deviation_sibling.js @@ -0,0 +1,104 @@ +import stdDeviationSibling from '../std_deviation_sibling'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('stdDeviationSibling(resp, panel, series)', () => { + let panel; + let series; + let resp; + beforeEach(() => { + panel = { + time_field: 'timestamp' + }; + series = { + chart_type: 'line', + stacked: false, + line_width: 1, + point_size: 1, + fill: 0, + color: '#F00', + id: 'test', + split_mode: 'everything', + metrics: [ + { + id: 'avgcpu', + type: 'avg', + field: 'cpu' + }, + { + id: 'sib', + type: 'std_deviation_bucket', + mode: 'band', + field: 'avgcpu' + } + ] + }; + resp = { + aggregations: { + test: { + sib: { + std_deviation: 0.23, + std_deviation_bounds: { + upper: 0.7, + lower: 0.01 + } + }, + timeseries: { + buckets: [ + { + key: 1, + avgcpu: { value: 0.23 } + }, + { + key: 2, + avgcpu: { value: 0.22 } + } + ] + } + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + stdDeviationSibling(resp, panel, series)(next)([]); + expect(next.calledOnce).to.equal(true); + }); + + it('creates a series', () => { + const next = results => results; + const results = stdDeviationSibling(resp, panel, series)(next)([]); + expect(results).to.have.length(2); + + expect(results[0]).to.eql({ + id: 'test:lower', + color: '#FF0000', + lines: { show: true, fill: false, lineWidth: 0 }, + points: { show: false }, + data: [ + [ 1, 0.01 ], + [ 2, 0.01 ] + ] + }); + + expect(results[1]).to.eql({ + id: 'test:upper', + label: 'Overall Std. Deviation of Average of cpu', + color: '#FF0000', + fillBetween: 'test:lower', + lines: { show: true, fill: 0.5, lineWidth: 0 }, + points: { show: false }, + data: [ + [ 1, 0.7 ], + [ 2, 0.7 ] + ] + }); + + }); + +}); + + + + diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_metric.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_metric.js new file mode 100644 index 00000000000000..f9d5be1760e949 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_metric.js @@ -0,0 +1,81 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import stdMetric from '../std_metric'; + +describe('stdMetric(resp, panel, series)', () => { + let panel; + let series; + let resp; + beforeEach(() => { + panel = { + time_field: 'timestamp' + }; + series = { + chart_type: 'line', + stacked: false, + line_width: 1, + point_size: 1, + fill: 0, + color: '#F00', + id: 'test', + split_mode: 'everything', + metrics: [{ id: 'avgmetric', type: 'avg', field: 'cpu' }] + }; + resp = { + aggregations: { + test: { + timeseries: { + buckets: [ + { + key: 1, + avgmetric: { value: 1 } + }, + { + key: 2, + avgmetric: { value: 2 } + } + ] + } + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + stdMetric(resp, panel, series)(next)([]); + expect(next.calledOnce).to.equal(true); + }); + + it('calls next when finished (percentile)', () => { + series.metrics[0].type = 'percentile'; + const next = sinon.spy(d => d); + const results = stdMetric(resp, panel, series)(next)([]); + expect(next.calledOnce).to.equal(true); + expect(results).to.have.length(0); + }); + + it('calls next when finished (std_deviation band)', () => { + series.metrics[0].type = 'std_deviation'; + series.metrics[0].mode = 'band'; + const next = sinon.spy(d => d); + const results = stdMetric(resp, panel, series)(next)([]); + expect(next.calledOnce).to.equal(true); + expect(results).to.have.length(0); + }); + + it('creates a series', () => { + const next = results => results; + const results = stdMetric(resp, panel, series)(next)([]); + expect(results).to.have.length(1); + expect(results[0]).to.have.property('color', '#FF0000'); + expect(results[0]).to.have.property('id', 'test'); + expect(results[0]).to.have.property('label', 'Average of cpu'); + expect(results[0]).to.have.property('lines'); + expect(results[0]).to.have.property('stack'); + expect(results[0]).to.have.property('bars'); + expect(results[0]).to.have.property('points'); + expect(results[0].data).to.eql([ [1,1], [2,2] ]); + }); + +}); diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_sibling.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_sibling.js new file mode 100644 index 00000000000000..114b34841b1c33 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/std_sibling.js @@ -0,0 +1,96 @@ +import stdSibling from '../std_sibling'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe('stdSibling(resp, panel, series)', () => { + let panel; + let series; + let resp; + beforeEach(() => { + panel = { + time_field: 'timestamp' + }; + series = { + chart_type: 'line', + stacked: false, + line_width: 1, + point_size: 1, + fill: 0, + color: '#F00', + id: 'test', + split_mode: 'everything', + metrics: [ + { + id: 'avgcpu', + type: 'avg', + field: 'cpu' + }, + { + id: 'sib', + type: 'std_deviation_bucket', + field: 'avgcpu' + } + ] + }; + resp = { + aggregations: { + test: { + sib: { + std_deviation: 0.23 + }, + timeseries: { + buckets: [ + { + key: 1, + avgcpu: { value: 0.23 } + }, + { + key: 2, + avgcpu: { value: 0.22 } + } + ] + } + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + stdSibling(resp, panel, series)(next)([]); + expect(next.calledOnce).to.equal(true); + }); + + it('calls next when std. deviation bands set', () => { + series.metrics[1].mode = 'band'; + const next = sinon.spy(results => results); + const results = stdSibling(resp, panel, series)(next)([]); + expect(next.calledOnce).to.equal(true); + expect(results).to.have.length(0); + }); + + it('creates a series', () => { + const next = results => results; + const results = stdSibling(resp, panel, series)(next)([]); + expect(results).to.have.length(1); + + expect(results[0]).to.eql({ + id: 'test', + label: 'Overall Std. Deviation of Average of cpu', + color: '#FF0000', + stack: false, + lines: { show: true, fill: 0, lineWidth: 1, steps: false }, + points: { show: true, radius: 1, lineWidth: 1 }, + bars: { fill: 0, lineWidth: 1, show: false }, + data: [ + [ 1, 0.23 ], + [ 2, 0.23 ] + ] + }); + + }); + +}); + + + diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/time_shift.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/time_shift.js new file mode 100644 index 00000000000000..c2d00b8b054998 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/__tests__/time_shift.js @@ -0,0 +1,71 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import timeShift from '../time_shift'; +import stdMetric from '../std_metric'; +import moment from 'moment'; + +describe('timeShift(resp, panel, series)', () => { + let panel; + let series; + let resp; + beforeEach(() => { + panel = { + time_field: 'timestamp' + }; + series = { + chart_type: 'line', + stacked: false, + line_width: 1, + offset_time: '1h', + point_size: 1, + fill: 0, + color: '#F00', + id: 'test', + split_mode: 'everything', + metrics: [{ id: 'avgmetric', type: 'avg', field: 'cpu' }] + }; + resp = { + aggregations: { + test: { + timeseries: { + buckets: [ + { + key: 1483225200000, + avgmetric: { value: 1 } + }, + { + key: 1483225210000, + avgmetric: { value: 2 } + } + ] + } + } + } + }; + }); + + it('calls next when finished', () => { + const next = sinon.spy(); + timeShift(resp, panel, series)(next)([]); + expect(next.calledOnce).to.equal(true); + }); + + it('creates a series', () => { + const next = timeShift(resp, panel, series)(results => results); + const results = stdMetric(resp, panel, series)(next)([]); + expect(results).to.have.length(1); + expect(results[0]).to.have.property('color', '#FF0000'); + expect(results[0]).to.have.property('id', 'test'); + expect(results[0]).to.have.property('label', 'Average of cpu'); + expect(results[0]).to.have.property('lines'); + expect(results[0]).to.have.property('stack'); + expect(results[0]).to.have.property('bars'); + expect(results[0]).to.have.property('points'); + expect(results[0].data).to.eql([ + [1483225200000 + 3600000, 1], + [1483225210000 + 3600000, 2] + ]); + }); + +}); + diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/_series_agg.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/_series_agg.js new file mode 100644 index 00000000000000..56c32bc344b052 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/_series_agg.js @@ -0,0 +1,71 @@ +import _ from 'lodash'; + +function mean(values) { + return _.sum(values) / values.length; +} + +const basic = fnName => targetSeries => { + const data = []; + _.zip(...targetSeries).forEach(row => { + const key = row[0][0]; + const values = row.map(r => r[1]); + const fn = _[fnName] || (() => null); + data.push([key, fn(values)]); + }); + return [data]; +}; + +const overall = fnName => targetSeries => { + const fn = _[fnName]; + const keys = []; + const values = []; + _.zip(...targetSeries).forEach(row => { + keys.push(row[0][0]); + values.push(fn(row.map(r => r[1]))); + }); + return [keys.map(k => [k, fn(values)])]; +}; + + +export default { + sum: basic('sum'), + max: basic('max'), + min: basic('min'), + mean(targetSeries) { + const data = []; + _.zip(...targetSeries).forEach(row => { + const key = row[0][0]; + const values = row.map(r => r[1]); + data.push([key, mean(values)]); + }); + return [data]; + }, + + + overall_max: overall('max'), + overall_min: overall('min'), + overall_sum: overall('sum'), + + overall_avg(targetSeries) { + const fn = mean; + const keys = []; + const values = []; + _.zip(...targetSeries).forEach(row => { + keys.push(row[0][0]); + values.push(_.sum(row.map(r => r[1]))); + }); + return [keys.map(k => [k, fn(values)])]; + }, + + cumlative_sum(targetSeries) { + const data = []; + let sum = 0; + _.zip(...targetSeries).forEach(row => { + const key = row[0][0]; + sum += _.sum(row.map(r => r[1])); + data.push([key, sum]); + }); + return [data]; + } + +}; diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/index.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/index.js new file mode 100644 index 00000000000000..b40bbdee817770 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/index.js @@ -0,0 +1,18 @@ +import percentile from './percentile'; +import seriesAgg from './series_agg'; +import stdDeviationBands from './std_deviation_bands'; +import stdDeviationSibling from './std_deviation_sibling'; +import stdMetric from './std_metric'; +import stdSibling from './std_sibling'; +import timeShift from './time_shift'; + +export default [ + percentile, + stdDeviationBands, + stdDeviationSibling, + stdMetric, + stdSibling, + seriesAgg, + timeShift +]; + diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/percentile.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/percentile.js new file mode 100644 index 00000000000000..ead6ed5146a44f --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/percentile.js @@ -0,0 +1,57 @@ +import _ from 'lodash'; +import getAggValue from '../../helpers/get_agg_value'; +import getDefaultDecoration from '../../helpers/get_default_decoration'; +import getSplits from '../../helpers/get_splits'; +import getLastMetric from '../../helpers/get_last_metric'; +export default function percentile(resp, panel, series) { + return next => results => { + const metric = getLastMetric(series); + if (metric.type !== 'percentile') return next(results); + + getSplits(resp, series).forEach((split) => { + metric.percentiles.forEach(percentile => { + const label = (split.label) + ` (${percentile.value})`; + const data = split.timeseries.buckets.map(bucket => { + const m = _.assign({}, metric, { percent: percentile.value }); + return [bucket.key, getAggValue(bucket, m)]; + }); + if (percentile.mode === 'band') { + const fillData = split.timeseries.buckets.map(bucket => { + const m = _.assign({}, metric, { percent: percentile.percentile }); + return [bucket.key, getAggValue(bucket, m)]; + }); + results.push({ + id: `${percentile.id}:${split.id}`, + color: split.color, + label, + data, + lines: { show: true, fill: percentile.shade, lineWidth: 0 }, + points: { show: false }, + legend: false, + fillBetween: `${percentile.id}:${split.id}:${percentile.percentile}` + }); + results.push({ + id: `${percentile.id}:${split.id}:${percentile.percentile}`, + color: split.color, + label, + data: fillData, + lines: { show: true, fill: false, lineWidth: 0 }, + legend: false, + points: { show: false } + }); + } else { + const decoration = getDefaultDecoration(series); + results.push({ + id: `${percentile.id}:${split.id}`, + color: split.color, + label, + data, + ...decoration + }); + } + }); + + }); + return next(results); + }; +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/series_agg.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/series_agg.js new file mode 100644 index 00000000000000..7f9c3b46c3056f --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/series_agg.js @@ -0,0 +1,36 @@ +import SeriesAgg from './_series_agg'; +import _ from 'lodash'; +import getDefaultDecoration from '../../helpers/get_default_decoration'; +import calculateLabel from '../../../../../public/components/lib/calculate_label'; +export default function seriesAgg(resp, panel, series) { + return next => results => { + if (series.metrics.some(m => m.type === 'series_agg')) { + const decoration = getDefaultDecoration(series); + + const targetSeries = []; + // Filter out the seires with the matching metric and store them + // in targetSeries + results = results.filter(s => { + if (s.id.split(/:/)[0] === series.id) { + targetSeries.push(s.data); + return false; + } + return true; + }); + const data = series.metrics.filter(m => m.type === 'series_agg') + .reduce((acc, m) => { + const fn = SeriesAgg[m.function]; + return fn && fn(acc) || acc; + }, targetSeries); + results.push({ + id: `${series.id}`, + label: series.label || calculateLabel(_.last(series.metrics), series.metrics), + color: series.color, + data: _.first(data), + ...decoration + }); + } + return next(results); + }; +} + diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_deviation_bands.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_deviation_bands.js new file mode 100644 index 00000000000000..dbb3a2ff5c8013 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_deviation_bands.js @@ -0,0 +1,33 @@ +import _ from 'lodash'; +import getSplits from '../../helpers/get_splits'; +import getLastMetric from '../../helpers/get_last_metric'; +import mapBucket from '../../helpers/map_bucket'; +export default function stdDeviationBands(resp, panel, series) { + return next => results => { + const metric = getLastMetric(series); + if (metric.type === 'std_deviation' && metric.mode === 'band') { + getSplits(resp, series).forEach((split) => { + const upper = split.timeseries.buckets.map(mapBucket(_.assign({}, metric, { mode: 'upper' }))); + const lower = split.timeseries.buckets.map(mapBucket(_.assign({}, metric, { mode: 'lower' }))); + results.push({ + id: `${split.id}:upper`, + label: split.label, + color: split.color, + lines: { show: true, fill: 0.5, lineWidth: 0 }, + points: { show: false }, + fillBetween: `${split.id}:lower`, + data: upper + }); + results.push({ + id: `${split.id}:lower`, + color: split.color, + lines: { show: true, fill: false, lineWidth: 0 }, + points: { show: false }, + data: lower + }); + }); + } + return next(results); + }; + +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_deviation_sibling.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_deviation_sibling.js new file mode 100644 index 00000000000000..401fd266d6fa37 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_deviation_sibling.js @@ -0,0 +1,48 @@ +import _ from 'lodash'; +import getSplits from '../../helpers/get_splits'; +import getLastMetric from '../../helpers/get_last_metric'; +import getSiblingAggValue from '../../helpers/get_sibling_agg_value'; +export default function stdDeviationSibling(resp, panel, series) { + return next => results => { + const metric = getLastMetric(series); + if (metric.mode === 'band' && metric.type === 'std_deviation_bucket') { + getSplits(resp, series).forEach((split) => { + + const mapBucketByMode = (mode) => { + return bucket => { + return [bucket.key, getSiblingAggValue(split, _.assign({}, metric, { mode }))]; + }; + }; + + const upperData = split.timeseries.buckets + .map(mapBucketByMode('upper')); + const lowerData = split.timeseries.buckets + .map(mapBucketByMode('lower')); + + results.push({ + id: `${split.id}:lower`, + lines: { show: true, fill: false, lineWidth: 0 }, + points: { show: false }, + color: split.color, + data: lowerData + }); + results.push({ + id: `${split.id}:upper`, + label: split.label, + color: split.color, + lines: { show: true, fill: 0.5, lineWidth: 0 }, + points: { show: false }, + fillBetween: `${split.id}:lower`, + data: upperData + }); + + }); + } + + return next(results); + }; + + + +} + diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_metric.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_metric.js new file mode 100644 index 00000000000000..862222162378ef --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_metric.js @@ -0,0 +1,29 @@ +import getDefaultDecoration from '../../helpers/get_default_decoration'; +import getSplits from '../../helpers/get_splits'; +import getLastMetric from '../../helpers/get_last_metric'; +import mapBucket from '../../helpers/map_bucket'; +export default function stdMetric(resp, panel, series) { + return next => results => { + const metric = getLastMetric(series); + if (metric.type === 'std_deviation' && metric.mode === 'band') { + return next(results); + } + if (metric.type === 'percentile') { + return next(results); + } + if (/_bucket$/.test(metric.type)) return next(results); + const decoration = getDefaultDecoration(series); + getSplits(resp, series).forEach((split) => { + const data = split.timeseries.buckets.map(mapBucket(metric)); + results.push({ + id: `${split.id}`, + label: split.label, + color: split.color, + data, + ...decoration + }); + }); + return next(results); + }; +} + diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_sibling.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_sibling.js new file mode 100644 index 00000000000000..61b9f4ae8be0ab --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/std_sibling.js @@ -0,0 +1,29 @@ +import getDefaultDecoration from '../../helpers/get_default_decoration'; +import getSplits from '../../helpers/get_splits'; +import getLastMetric from '../../helpers/get_last_metric'; +import getSiblingAggValue from '../../helpers/get_sibling_agg_value'; +export default function stdSibling(resp, panel, series) { + return next => results => { + const metric = getLastMetric(series); + + if (!/_bucket$/.test(metric.type)) return next(results); + if (metric.type === 'std_deviation_bucket' && metric.mode === 'band') return next(results); + + const decoration = getDefaultDecoration(series); + getSplits(resp, series).forEach((split) => { + const data = split.timeseries.buckets.map(bucket => { + return [bucket.key, getSiblingAggValue(split, metric)]; + }); + results.push({ + id: split.id, + label: split.label, + color: split.color, + data, + ...decoration + }); + }); + return next(results); + }; + + +} diff --git a/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/time_shift.js b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/time_shift.js new file mode 100644 index 00000000000000..f8b8a201922389 --- /dev/null +++ b/src/core_plugins/metrics/server/lib/vis_data/response_processors/series/time_shift.js @@ -0,0 +1,19 @@ +import _ from 'lodash'; +import moment from 'moment'; +export default function timeShift(resp, panel, series) { + return next => results => { + if (/^([\d]+)([shmdwMy]|ms)$/.test(series.offset_time)) { + const matches = series.offset_time.match(/^([\d]+)([shmdwMy]|ms)$/); + if (matches) { + const offsetValue = matches[1]; + const offsetUnit = matches[2]; + results.forEach(item => { + if (_.startsWith(item.id, series.id)) { + item.data = item.data.map(row => [moment(row[0]).add(offsetValue, offsetUnit).valueOf(), row[1]]); + } + }); + } + } + return next(results); + }; +} diff --git a/src/core_plugins/metrics/server/routes/fields.js b/src/core_plugins/metrics/server/routes/fields.js new file mode 100644 index 00000000000000..d812b897e5af98 --- /dev/null +++ b/src/core_plugins/metrics/server/routes/fields.js @@ -0,0 +1,15 @@ +import getFields from '../lib/get_fields'; +export default (server) => { + + server.route({ + path: '/api/metrics/fields', + method: 'GET', + handler: (req, reply) => { + return getFields(req) + .then(reply) + .catch(err => reply([])); + } + }); + +}; + diff --git a/src/core_plugins/metrics/server/routes/vis.js b/src/core_plugins/metrics/server/routes/vis.js new file mode 100644 index 00000000000000..54e299f18afe9c --- /dev/null +++ b/src/core_plugins/metrics/server/routes/vis.js @@ -0,0 +1,19 @@ +import getVisData from '../lib/get_vis_data'; +import _ from 'lodash'; +import Boom from 'boom'; +export default (server) => { + + server.route({ + path: '/api/metrics/vis/data', + method: 'POST', + handler: (req, reply) => { + return getVisData(req) + .then(reply) + .catch(err => { + console.error(err.stack); + reply(Boom.wrap(err, 400)); + }); + } + }); + +}; diff --git a/src/core_plugins/timelion/public/vis/index.js b/src/core_plugins/timelion/public/vis/index.js index b1780246ef4226..3ea9b78377e471 100644 --- a/src/core_plugins/timelion/public/vis/index.js +++ b/src/core_plugins/timelion/public/vis/index.js @@ -16,7 +16,7 @@ define(function (require) { // Vis object of this type. return new TemplateVisType({ name: 'timelion', - title: 'Timeseries', + title: 'Timelion', icon: 'fa-clock-o', description: 'Create timeseries charts using the timelion expression language. ' + 'Perfect for computing and combining timeseries sets with functions such as derivatives and moving averages', diff --git a/test/functional/apps/visualize/_chart_types.js b/test/functional/apps/visualize/_chart_types.js index 8bb90b0445292f..170364ad3ff9c5 100644 --- a/test/functional/apps/visualize/_chart_types.js +++ b/test/functional/apps/visualize/_chart_types.js @@ -17,7 +17,7 @@ bdd.describe('visualize app', function describeIndexTests() { bdd.it('should show the correct chart types', function () { const expectedChartTypes = [ 'Area chart', 'Data table', 'Heatmap chart', 'Horizontal bar chart', 'Line chart', 'Markdown widget', - 'Metric', 'Pie chart', 'Tag cloud', 'Tile map', 'Timeseries', 'Vertical bar chart' + 'Metric', 'Pie chart', 'Tag cloud', 'Tile map', 'Time Series Visual Builder', 'Timelion', 'Vertical bar chart' ]; // find all the chart types and make sure there all there return PageObjects.visualize.getChartTypes()