diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb index 440654b0eb4..5e4d3fd7db2 100644 --- a/app/controllers/hosts_controller.rb +++ b/app/controllers/hosts_controller.rb @@ -309,15 +309,11 @@ def vm end def runtime - render :partial => 'runtime' - rescue ActionView::Template::Error => exception - process_ajax_error exception, 'fetch runtime chart information' + render :json => helpers.runtime_chart(params[:range].empty? ? 7.days.ago : params[:range].to_i.days.ago) end def resources - render :partial => 'resources' - rescue ActionView::Template::Error => exception - process_ajax_error exception, 'fetch resources chart information' + render :json => helpers.resources_chart(params[:range].empty? ? 7.days.ago : params[:range].to_i.days.ago) end def templates diff --git a/app/helpers/hosts_helper.rb b/app/helpers/hosts_helper.rb index 1223a368abc..cf4983d0727 100644 --- a/app/helpers/hosts_helper.rb +++ b/app/helpers/hosts_helper.rb @@ -216,11 +216,13 @@ def resources_chart(timerange = 1.day.ago) failed_restarts << [r.reported_at.to_i * 1000, r.failed_restarts ] skipped << [r.reported_at.to_i * 1000, r.skipped ] end - [{:label => _("Applied"), :data => applied, :color => '#89A54E'}, - {:label => _("Failed"), :data => failed, :color => '#AA4643'}, - {:label => _("Failed restarts"), :data => failed_restarts, :color => '#EC971F'}, - {:label => _("Skipped"), :data => skipped, :color => '#80699B'}, - {:label => _("Restarted"), :data => restarted, :color => '#4572A7'}] + {:results => [ + {:label => _("Applied"), :data => applied, :color => '#89A54E'}, + {:label => _("Failed"), :data => failed, :color => '#AA4643'}, + {:label => _("Failed restarts"), :data => failed_restarts, :color => '#EC971F'}, + {:label => _("Skipped"), :data => skipped, :color => '#80699B'}, + {:label => _("Restarted"), :data => restarted, :color => '#4572A7'} + ]} end def runtime_chart(timerange = 1.day.ago) @@ -229,7 +231,10 @@ def runtime_chart(timerange = 1.day.ago) config << [r.reported_at.to_i * 1000, r.config_retrieval] runtime << [r.reported_at.to_i * 1000, r.runtime] end - [{:label => _("Config Retrieval"), :data => config, :color => '#AA4643'}, {:label => _("Runtime"), :data => runtime, :color => '#4572A7'}] + {:results => [ + {:label => _("Config Retrieval"), :data => config, :color => '#AA4643'}, + {:label => _("Runtime"), :data => runtime, :color => '#4572A7'} + ]} end def reports_show diff --git a/app/views/hosts/_resources.html.erb b/app/views/hosts/_resources.html.erb deleted file mode 100644 index dc5c93d15ee..00000000000 --- a/app/views/hosts/_resources.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= flot_chart('resource_graph', '', _("Number of Events"), resources_chart(params["range"].to_i.days.ago)) %> diff --git a/app/views/hosts/_runtime.html.erb b/app/views/hosts/_runtime.html.erb deleted file mode 100644 index 4efcfe2ef6e..00000000000 --- a/app/views/hosts/_runtime.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= flot_chart('runtime_graph','', _('Time in Seconds'), runtime_chart(params["range"].to_i.days.ago), :class=>"statistics-chart stack") %> diff --git a/app/views/hosts/show.html.erb b/app/views/hosts/show.html.erb index bbfbfb4ec17..1002f1016dc 100644 --- a/app/views/hosts/show.html.erb +++ b/app/views/hosts/show.html.erb @@ -75,16 +75,22 @@

<%= _("Runtime") %>

<%= n_("last %s day", "last %s days", @range) % @range %>
-
- <%= spinner(_('Loading runtime information ...')) %> -
+
+ <%= mount_react_component('HostChart', '#host_runtime', { + :url => runtime_host_path(@host, :range => @range), + :name => 'runtime', + :type => 'area' + }.to_json) %>

<%= _("Resources") %>

<%= n_("last %s day", "last %s days", @range) % @range %>
-
- <%= spinner(_('Loading resources information ...')) %> -
+
+ <%= mount_react_component('HostChart', '#host_resources', { + :url => resources_host_path(@host, :range => @range), + :name => 'resource', + :type => 'area' + }.to_json) %>
diff --git a/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/DonutChart.test.js b/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/DonutChart.test.js index f6f4e2de88e..87079bc448e 100644 --- a/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/DonutChart.test.js +++ b/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/DonutChart.test.js @@ -1,22 +1,16 @@ -import { shallow } from 'enzyme'; -import toJson from 'enzyme-to-json'; -import React from 'react'; import { mockStoryData, emptyData } from './DonutChart.fixtures'; import DonutChart from './'; -import * as chartService from '../../../../../services/ChartService'; +import { testComponentSnapshotsWithFixtures } from '../../../../common/testHelpers'; jest.unmock('./'); -describe('renders DonutChart', () => { - it('render donut chart', () => { - chartService.getDonutChartConfig = jest.fn(() => mockStoryData); - const wrapper = shallow(); - - expect(toJson(wrapper)).toMatchSnapshot(); - }); - it('render empty state', () => { - chartService.getDonutChartConfig = jest.fn(() => emptyData); - const wrapper = shallow(); - - expect(toJson(wrapper)).toMatchSnapshot(); - }); -}); +const fixtures = { + 'render donut chart': { + data: null, + getConfig: () => mockStoryData, + }, + 'render empty donut chart': { + data: null, + getConfig: () => emptyData, + }, +}; +testComponentSnapshotsWithFixtures(DonutChart, fixtures); diff --git a/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/__snapshots__/DonutChart.test.js.snap b/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/__snapshots__/DonutChart.test.js.snap index 27017eb380f..5bcd02c0a87 100644 --- a/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/__snapshots__/DonutChart.test.js.snap +++ b/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/__snapshots__/DonutChart.test.js.snap @@ -1,16 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders DonutChart render donut chart 1`] = ` - `; -exports[`renders DonutChart render empty state 1`] = ` - `; diff --git a/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/index.js b/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/index.js index 487b0fc69f5..126ed470fc4 100644 --- a/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/index.js +++ b/webpack/assets/javascripts/react_app/components/common/charts/DonutChart/index.js @@ -1,29 +1,21 @@ import React from 'react'; import { DonutChart as PfDonutChart } from 'patternfly-react'; import { getDonutChartConfig } from '../../../../../services/ChartService'; -import MessageBox from '../../MessageBox'; +import { chartWithNoDataMessage } from '../common/chartWithNoDataMessage'; + +const DonutChartWithNoDataMsgBox = chartWithNoDataMessage(PfDonutChart); const DonutChart = ({ data, onclick, config = 'regular', - noDataMsg = __('No data available'), title = { type: 'percent' }, unloadData = false, - -}) => { - const chartConfig = getDonutChartConfig({ data, config, onclick }); - - if (chartConfig.data.columns.length > 0) { - return ( - - ); - } - return ; -}; + getConfig = getDonutChartConfig, +}) => ; export default DonutChart; diff --git a/webpack/assets/javascripts/react_app/components/common/charts/TimeseriesChart/TimeseriesChart.test.js b/webpack/assets/javascripts/react_app/components/common/charts/TimeseriesChart/TimeseriesChart.test.js new file mode 100644 index 00000000000..5b3d4f29f83 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/charts/TimeseriesChart/TimeseriesChart.test.js @@ -0,0 +1,22 @@ +import TimeseriesChart from './'; +import { timeseriesChartConfig } from '../../../../../services/ChartService.consts'; +import { testComponentSnapshotsWithFixtures } from '../../../../common/testHelpers'; + +jest.unmock('./'); +const fixtures = { + 'render timeseries chart': { + data: null, + getConfig: () => Object.assign({}, { + ...timeseriesChartConfig, + data: { + x: 'time', + columns: [['time', 15029999123], ['Runtime', 8], ['Config Retrieval', 35]], + }, + }), + }, + 'render empty timeseries chart': { + data: null, + getConfig: () => timeseriesChartConfig, + }, +}; +testComponentSnapshotsWithFixtures(TimeseriesChart, fixtures); diff --git a/webpack/assets/javascripts/react_app/components/common/charts/TimeseriesChart/__snapshots__/TimeseriesChart.test.js.snap b/webpack/assets/javascripts/react_app/components/common/charts/TimeseriesChart/__snapshots__/TimeseriesChart.test.js.snap new file mode 100644 index 00000000000..1d4a8f6b063 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/charts/TimeseriesChart/__snapshots__/TimeseriesChart.test.js.snap @@ -0,0 +1,94 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`render empty timeseries chart 1`] = ` + +`; + +exports[`render timeseries chart 1`] = ` + +`; diff --git a/webpack/assets/javascripts/react_app/components/common/charts/TimeseriesChart/index.js b/webpack/assets/javascripts/react_app/components/common/charts/TimeseriesChart/index.js new file mode 100644 index 00000000000..0823305b212 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/charts/TimeseriesChart/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { LineChart as PfLineChart } from 'patternfly-react'; +import { chartWithNoDataMessage } from '../common/chartWithNoDataMessage'; +import { getTimeseriesChartConfig } from '../../../../../services/ChartService'; + +const TimeseriesChartWithNoDataMsgBox = chartWithNoDataMessage(PfLineChart); + +const TimeseriesChart = ({ + data, + type = 'line', + unloadData = false, + getConfig = getTimeseriesChartConfig, +}) => ; + +export default TimeseriesChart; diff --git a/webpack/assets/javascripts/react_app/components/common/charts/common/__snapshots__/chartWithNoDataMessage.test.js.snap b/webpack/assets/javascripts/react_app/components/common/charts/common/__snapshots__/chartWithNoDataMessage.test.js.snap new file mode 100644 index 00000000000..f648c866738 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/charts/common/__snapshots__/chartWithNoDataMessage.test.js.snap @@ -0,0 +1,163 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`common chart components render a messagebox if no data 1`] = ` + + +
+
+
+ No data available +
+
+ + +`; + +exports[`common chart components render a messagebox if no data 2`] = ` + + +
+
+
+ No data available +
+
+ + +`; + +exports[`common chart components render a messagebox if no data 3`] = ` + + +
+
+
+ No data available +
+
+ + +`; + +exports[`common chart components render a messagebox if no data 4`] = ` + + +
+
+
+ No data available +
+
+ + +`; + +exports[`common chart components render a messagebox if no data 5`] = ` + + +
+
+
+ No data available +
+
+ + +`; + +exports[`common chart components render chart if there is data 1`] = ` + + +

+ Chart +

+
+
+`; diff --git a/webpack/assets/javascripts/react_app/components/common/charts/common/chartWithNoDataMessage.js b/webpack/assets/javascripts/react_app/components/common/charts/common/chartWithNoDataMessage.js new file mode 100644 index 00000000000..880c61dd41a --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/charts/common/chartWithNoDataMessage.js @@ -0,0 +1,10 @@ +import React from 'react'; +import MessageBox from '../../MessageBox'; + +export const chartWithNoDataMessage = (WrappedComp, noDataMsg = 'No data available') => (props) => { + const columns = props && props.data && props.data.columns ? props.data.columns : []; + if (columns.length > 0) { + return (); + } + return ; +}; diff --git a/webpack/assets/javascripts/react_app/components/common/charts/common/chartWithNoDataMessage.test.js b/webpack/assets/javascripts/react_app/components/common/charts/common/chartWithNoDataMessage.test.js new file mode 100644 index 00000000000..8a9896dbb01 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/charts/common/chartWithNoDataMessage.test.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import toJson from 'enzyme-to-json'; + +import { chartWithNoDataMessage } from './chartWithNoDataMessage'; + +describe('common chart components', () => { + const Chart = chartWithNoDataMessage(() =>

Chart

); + it('render a messagebox if no data', () => { + [undefined, null, {}, { data: {} }, { data: { columns: [] } }] + .forEach(props => expect(toJson(mount())).toMatchSnapshot()); + }); + it('render chart if there is data', () => { + expect(toJson(mount())).toMatchSnapshot(); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/componentRegistry.js b/webpack/assets/javascripts/react_app/components/componentRegistry.js index dc84d6709fa..897582d0dac 100644 --- a/webpack/assets/javascripts/react_app/components/componentRegistry.js +++ b/webpack/assets/javascripts/react_app/components/componentRegistry.js @@ -12,6 +12,7 @@ import BreadcrumbBar from './BreadcrumbBar'; import FactChart from './factCharts'; import Pagination from './Pagination/Pagination'; import AuditsList from './AuditsList'; +import HostChart from './hosts/chart/'; const componentRegistry = { registry: {}, @@ -72,6 +73,7 @@ const coreComponets = [ { name: 'FactChart', type: FactChart }, { name: 'Pagination', type: Pagination }, { name: 'AuditsList', type: AuditsList }, + { name: 'HostChart', type: HostChart }, ]; componentRegistry.registerMultiple(coreComponets); diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/HostChart.js b/webpack/assets/javascripts/react_app/components/hosts/chart/HostChart.js new file mode 100644 index 00000000000..0022bad1efa --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/HostChart.js @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import MessageBox from '../../common/MessageBox'; +import { noop } from '../../../common/helpers'; +import Normalizer from './Normalizer'; +import Loader from '../../common/Loader'; +import { STATUS } from '../../../constants'; +import TimeseriesChart from '../../common/charts/TimeseriesChart'; + +class HostChart extends React.Component { + componentDidMount() { + const { data: { name, url } } = this.props; + this.props.getChartData(url, name); + } + render() { + const { + charts, data: { name, type }, + } = this.props; + + const chartData = Object.keys(charts || {}) + .filter(cName => cName === name) + .map(cName => charts[cName]); + const data = chartData.length > 0 ? + Object.assign({}, { ...chartData[0], results: Normalizer(chartData[0].results) }) : + { status: STATUS.PENDING, results: [], error: null }; + + return ( + + {[ + , + , + ]} + + ); + } +} + +HostChart.propTypes = { + data: PropTypes.shape({ + url: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + type: PropTypes.oneOf(['area', 'line']), + }), + getChartData: PropTypes.func.isRequired, + isLoadingData: PropTypes.bool, + chartData: PropTypes.object, +}; + +HostChart.defaultProps = { + data: { + type: 'line', + }, + getChartData: noop, +}; + +export default HostChart; diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/HostChart.stories.js b/webpack/assets/javascripts/react_app/components/hosts/chart/HostChart.stories.js new file mode 100644 index 00000000000..4dc70110e43 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/HostChart.stories.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import HostChart from './HostChart'; +import { STATUS } from '../../../constants'; + +const getConfig = (hostname, status) => ({ + charts: { + [hostname]: { + results: [], + status, + }, + }, + data: { + name: hostname, + url: `host/${hostname}/resources`, + }, +}); + +storiesOf('HostChart', module) + .add('Loading', () => ( + + )) + .add('Without Data', () => ( + + )) + .add('Line Chart', () => ( + + )) + .add('Area Chart', () => ( + + )); diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/HostChartActions.js b/webpack/assets/javascripts/react_app/components/hosts/chart/HostChartActions.js new file mode 100644 index 00000000000..e892419ebc0 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/HostChartActions.js @@ -0,0 +1,13 @@ +import { ajaxRequestAction } from '../../../redux/actions/common'; +import * as tsConsts from './HostChartConsts'; + +export const getChartData = (url, name) => (dispatch) => { + ajaxRequestAction({ + dispatch, + requestAction: tsConsts.TIMESERIES_REQUEST, + successAction: tsConsts.TIMESERIES_SUCCESS, + failedAction: tsConsts.TIMESERIES_FAILURE, + url, + item: { name }, + }); +}; diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/HostChartConsts.js b/webpack/assets/javascripts/react_app/components/hosts/chart/HostChartConsts.js new file mode 100644 index 00000000000..d4d066f0c6f --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/HostChartConsts.js @@ -0,0 +1,3 @@ +export const TIMESERIES_REQUEST = 'TIMESERIES_REQUEST'; +export const TIMESERIES_SUCCESS = 'TIMESERIES_SUCCESS'; +export const TIMESERIES_FAILURE = 'TIMESERIES_FAILURE'; diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/HostChartReducer.js b/webpack/assets/javascripts/react_app/components/hosts/chart/HostChartReducer.js new file mode 100644 index 00000000000..c88a3d010bf --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/HostChartReducer.js @@ -0,0 +1,31 @@ +import Immutable from 'seamless-immutable'; +import { TIMESERIES_REQUEST, TIMESERIES_FAILURE, TIMESERIES_SUCCESS } from './HostChartConsts'; +import { STATUS } from '../../../constants'; + +const hostChartReducer = (state = Immutable({}), action) => { + const { payload } = action; + switch (action.type) { + case TIMESERIES_REQUEST: + return state.setIn(['charts', payload.name], { + error: null, + results: [], + status: STATUS.PENDING, + }); + case TIMESERIES_FAILURE: + return state.setIn(['charts', payload.item.name], { + error: payload.error.message, + results: [], + status: STATUS.ERROR, + }); + case TIMESERIES_SUCCESS: + return state.setIn(['charts', payload.name], { + error: null, + results: payload.results, + status: STATUS.RESOLVED, + }); + default: + return state; + } +}; + +export default hostChartReducer; diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/Normalizer.js b/webpack/assets/javascripts/react_app/components/hosts/chart/Normalizer.js new file mode 100644 index 00000000000..5d79a5632a6 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/Normalizer.js @@ -0,0 +1,20 @@ +const Normalizer = (vector) => { + if (vector.length === 0) { + return { columns: [] }; + } + const data = { + x: 'time', + }; + const timeFrame = vector[0].data.map(ts => ts[0]); + const tsItems = vector.map(item => [item.label, ...item.data.map(ts => ts[1])]); + data.columns = [['time', ...timeFrame], ...tsItems]; + const colorItems = vector.filter(item => Object.prototype.hasOwnProperty.call(item, 'color')); + if (colorItems.length === 0) { + return data; + } + const colorReducer = (acc, cur) => Object.assign({}, { ...acc, [cur.label]: cur.color }); + data.colors = { ...colorItems.reduce(colorReducer, {}) }; + return data; +}; + +export default Normalizer; diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/HostChart.test.js b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/HostChart.test.js new file mode 100644 index 00000000000..2705733c00b --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/HostChart.test.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import HostChart from '../HostChart'; +import { testComponentSnapshotsWithFixtures } from '../../../../common/testHelpers'; +import { STATUS } from '../../../../constants'; + +jest.unmock('../../../../../services/ChartService'); + +const fixtures = { + 'render empty state (before dispatching the REQUEST action)': { + data: { + url: 'something', + name: 'chartName', + }, + charts: {}, + }, + 'render error mode': { + data: { + url: '/host/localhost/runtime', + name: 'runtime', + }, + charts: { + runtime: { + status: STATUS.ERROR, + results: [], + error: 'bad news', + }, + }, + }, + 'render loading mode': { + data: { + url: '#', + name: 'ts', + }, + charts: { + ts: { + status: STATUS.PENDING, + results: [], + error: null, + }, + }, + }, + 'render data resolved mode': { + data: { + url: 'host/domain1/resources', + name: 'resources', + }, + charts: { + resources: { + status: STATUS.RESOLVED, + results: [{ label: 'data1', data: [[129321, 100]] }], + error: null, + }, + }, + }, +}; +testComponentSnapshotsWithFixtures(HostChart, fixtures); + +describe('HostChart', () => { + it('should call getChartData on mount', () => { + const url = 'url'; + const name = 'name'; + const getChartData = jest.fn(); + shallow(); + expect(getChartData).toBeCalledWith(url, name); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/HostChartActions.test.js b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/HostChartActions.test.js new file mode 100644 index 00000000000..8185dc3ea07 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/HostChartActions.test.js @@ -0,0 +1,23 @@ +import { getChartData } from '../HostChartActions'; +import { ajaxRequestAction } from '../../../../redux/actions/common'; +import { TIMESERIES_REQUEST, TIMESERIES_SUCCESS, TIMESERIES_FAILURE } from '../HostChartConsts'; + +jest.mock('../../../../redux/actions/common'); + +describe('Timeseries chart actions', () => { + it('should call ajaxRequestAction on getChartData', () => { + const url = '/runtime/ts'; + const name = 'runtime'; + const dispatch = jest.fn(); + + getChartData(url, name)(dispatch); + expect(ajaxRequestAction).toBeCalledWith({ + dispatch, + url, + item: { name }, + requestAction: TIMESERIES_REQUEST, + successAction: TIMESERIES_SUCCESS, + failedAction: TIMESERIES_FAILURE, + }); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/HostChartReducer.test.js b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/HostChartReducer.test.js new file mode 100644 index 00000000000..43c3c765e50 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/HostChartReducer.test.js @@ -0,0 +1,40 @@ +import { TIMESERIES_REQUEST, TIMESERIES_FAILURE, TIMESERIES_SUCCESS } from '../HostChartConsts'; +import reducer from '../HostChartReducer'; +import { testReducerSnapshotWithFixtures } from '../../../../common/testHelpers'; + +const fixtures = { + 'should handle empty action': {}, + 'should handle TIMESERIES_REQUEST': { + action: { + type: TIMESERIES_REQUEST, + payload: { + name: 'ts1', + results: [{ label: 'data', data: [[1, 1], [2, 2]] }], + }, + }, + }, + 'should handle TIMESERIES_FAILURE': { + action: { + type: TIMESERIES_FAILURE, + payload: { error: new Error('Oops, something is wrong'), item: { name: 'ts1' } }, + }, + }, + 'should handle TIMESERIES_SUCCESS': { + action: { + type: TIMESERIES_SUCCESS, + payload: { + name: 'ts1', + results: [{ + label: 'data-1', + data: [[10, 20], [20, 20], [30, 0]], + color: 'blue', + }, { + label: 'data-2', + data: [[10, 0], [20, 0], [30, 0]], + }], + }, + }, + }, +}; + +describe('TimeseriesChart reducer', () => testReducerSnapshotWithFixtures(reducer, fixtures)); diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/Normalizer.test.js b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/Normalizer.test.js new file mode 100644 index 00000000000..673405d98b6 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/Normalizer.test.js @@ -0,0 +1,50 @@ +import Normalizer from '../Normalizer'; + +describe('Normalize TimeseriesChart response', () => { + it('should normalize an empty response', () => { + expect(Normalizer([])).toEqual({ + columns: [], + }); + }); + it('should normalize a response with single item', () => { + const resp = [{ + label: 'item', + data: [[10, 20], [20, 20], [30, 10]], + }]; + expect(Normalizer(resp)).toEqual({ + x: 'time', + columns: [['time', 10, 20, 30], ['item', 20, 20, 10]], + }); + }); + it('should normalize a response with more than one item', () => { + const resp = [{ + label: 'item1', + data: [[10, 20], [12, 44]], + }, + { + label: 'item2', + data: [[10, 0], [12, 0]], + }]; + expect(Normalizer(resp)).toEqual({ + x: 'time', + columns: [['time', 10, 12], ['item1', 20, 44], ['item2', 0, 0]], + }); + }); + it('should normalize colors in a response', () => { + const resp = [{ + label: 'item1', + data: [[10, 20]], + color: 'black', + }, + { + label: 'item2', + data: [[10, 0]], + color: 'green', + }]; + expect(Normalizer(resp)).toEqual({ + x: 'time', + columns: [['time', 10], ['item1', 20], ['item2', 0]], + colors: { item1: 'black', item2: 'green' }, + }); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/__snapshots__/HostChart.test.js.snap b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/__snapshots__/HostChart.test.js.snap new file mode 100644 index 00000000000..8297c3d25b4 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/__snapshots__/HostChart.test.js.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`render data resolved mode 1`] = ` + + + + +`; + +exports[`render empty state (before dispatching the REQUEST action) 1`] = ` + + + + +`; + +exports[`render error mode 1`] = ` + + + + +`; + +exports[`render loading mode 1`] = ` + + + + +`; diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/__snapshots__/HostChartReducer.test.js.snap b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/__snapshots__/HostChartReducer.test.js.snap new file mode 100644 index 00000000000..819a9f0c7e5 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/__tests__/__snapshots__/HostChartReducer.test.js.snap @@ -0,0 +1,75 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TimeseriesChart reducer should handle TIMESERIES_FAILURE 1`] = ` +Object { + "charts": Object { + "ts1": Object { + "error": "Oops, something is wrong", + "results": Array [], + "status": "ERROR", + }, + }, +} +`; + +exports[`TimeseriesChart reducer should handle TIMESERIES_REQUEST 1`] = ` +Object { + "charts": Object { + "ts1": Object { + "error": null, + "results": Array [], + "status": "PENDING", + }, + }, +} +`; + +exports[`TimeseriesChart reducer should handle TIMESERIES_SUCCESS 1`] = ` +Object { + "charts": Object { + "ts1": Object { + "error": null, + "results": Array [ + Object { + "color": "blue", + "data": Array [ + Array [ + 10, + 20, + ], + Array [ + 20, + 20, + ], + Array [ + 30, + 0, + ], + ], + "label": "data-1", + }, + Object { + "data": Array [ + Array [ + 10, + 0, + ], + Array [ + 20, + 0, + ], + Array [ + 30, + 0, + ], + ], + "label": "data-2", + }, + ], + "status": "RESOLVED", + }, + }, +} +`; + +exports[`TimeseriesChart reducer should handle empty action 1`] = `Object {}`; diff --git a/webpack/assets/javascripts/react_app/components/hosts/chart/index.js b/webpack/assets/javascripts/react_app/components/hosts/chart/index.js new file mode 100644 index 00000000000..c685c2ef4eb --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/hosts/chart/index.js @@ -0,0 +1,12 @@ +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; + +import * as actions from './HostChartActions'; +import reducer from './HostChartReducer'; +import HostChart from './HostChart'; + +const mapStateToProps = ({ hostChart }) => ({ ...hostChart }); +const mapDispatchToProps = dispatch => bindActionCreators(actions, dispatch); + +export const reducers = { hostChart: reducer }; +export default connect(mapStateToProps, mapDispatchToProps)(HostChart); diff --git a/webpack/assets/javascripts/react_app/redux/reducers/index.js b/webpack/assets/javascripts/react_app/redux/reducers/index.js index c79a738a64e..cba4a278588 100644 --- a/webpack/assets/javascripts/react_app/redux/reducers/index.js +++ b/webpack/assets/javascripts/react_app/redux/reducers/index.js @@ -7,6 +7,7 @@ import notifications from './notifications/'; import toasts from './toasts'; import { reducers as passwordStrengthReducers } from '../../components/PasswordStrength'; import { reducers as breadcrumbBarReducers } from '../../components/BreadcrumbBar'; +import { reducers as hostChartReducers } from '../../components/hosts/chart'; import factChart from './factCharts/'; export function combineReducersAsync(asyncReducers) { @@ -17,6 +18,7 @@ export function combineReducersAsync(asyncReducers) { hosts, notifications, toasts, + ...hostChartReducers, ...passwordStrengthReducers, ...breadcrumbBarReducers, ...asyncReducers, diff --git a/webpack/assets/javascripts/services/ChartService.consts.js b/webpack/assets/javascripts/services/ChartService.consts.js index 5cda413b811..9c433cab2f0 100644 --- a/webpack/assets/javascripts/services/ChartService.consts.js +++ b/webpack/assets/javascripts/services/ChartService.consts.js @@ -33,6 +33,33 @@ export const donutChartConfig = { size: enums.SIZE.REGULAR, }; +export const timeseriesChartConfig = { + data: { + x: 'time', + columns: [], + }, + axis: { + x: { + type: 'timeseries', + tick: { + fit: false, + }, + }, + }, + zoom: { + enabled: true, + }, + subchart: { + show: true, + }, + tooltip: { + grouped: false, + format: { + title: d => new Date(d).toUTCString(), + }, + }, +}; + export const donutLargeChartConfig = { ...donutChartConfig, size: enums.SIZE.LARGE, diff --git a/webpack/assets/javascripts/services/ChartService.js b/webpack/assets/javascripts/services/ChartService.js index 66427ea4f01..73a0541bbcc 100644 --- a/webpack/assets/javascripts/services/ChartService.js +++ b/webpack/assets/javascripts/services/ChartService.js @@ -1,6 +1,6 @@ import uuidV1 from 'uuid/v1'; import Immutable from 'seamless-immutable'; -import { donutChartConfig, donutLargeChartConfig } from './ChartService.consts'; +import { timeseriesChartConfig, donutChartConfig, donutLargeChartConfig } from './ChartService.consts'; const sizeConfig = { regular: donutChartConfig, @@ -107,3 +107,11 @@ export const navigateToSearch = (url, data) => { } window.location.href = setUrl; }; + +export const getTimeseriesChartConfig = ({ + data, type = 'line', id = uuidV1(), +}) => ({ + ...timeseriesChartConfig, + id, + data: { ...(data || { columns: [] }), type }, +}); diff --git a/webpack/assets/javascripts/services/ChartServices.test.js b/webpack/assets/javascripts/services/ChartServices.test.js index cec5ea9fd86..2deb5aa93ec 100644 --- a/webpack/assets/javascripts/services/ChartServices.test.js +++ b/webpack/assets/javascripts/services/ChartServices.test.js @@ -1,5 +1,5 @@ import Immutable from 'seamless-immutable'; -import { getDonutChartConfig } from './ChartService'; +import { getDonutChartConfig, getTimeseriesChartConfig } from './ChartService'; import { zeroedData, mixedData, dataWithLongLabels } from '../react_app/components/common/charts/DonutChart/DonutChart.fixtures'; jest.unmock('./ChartService'); @@ -37,3 +37,19 @@ describe('getDonutChartConfig', () => { }))).toMatchSnapshot(); }); }); + +describe('getTimeseriesChartConfig', () => { + it('should render an empty line chart by default', () => { + expect(getTimeseriesChartConfig({ + data: null, + id: 'my-data', + })).toMatchSnapshot(); + }); + + it('should render a line chart from data', () => { + expect(getTimeseriesChartConfig({ + data: { columns: [['time', 1, 2, 3], ['data', 10, 20, 0]] }, + id: 'my-data', + })).toMatchSnapshot(); + }); +}); diff --git a/webpack/assets/javascripts/services/__snapshots__/ChartServices.test.js.snap b/webpack/assets/javascripts/services/__snapshots__/ChartServices.test.js.snap index ca2b2eb82a9..9390b6c5f0a 100644 --- a/webpack/assets/javascripts/services/__snapshots__/ChartServices.test.js.snap +++ b/webpack/assets/javascripts/services/__snapshots__/ChartServices.test.js.snap @@ -202,3 +202,76 @@ Object { }, } `; + +exports[`getTimeseriesChartConfig should render a line chart from data 1`] = ` +Object { + "axis": Object { + "x": Object { + "tick": Object { + "fit": false, + }, + "type": "timeseries", + }, + }, + "data": Object { + "columns": Array [ + Array [ + "time", + 1, + 2, + 3, + ], + Array [ + "data", + 10, + 20, + 0, + ], + ], + "type": "line", + }, + "id": "my-data", + "subchart": Object { + "show": true, + }, + "tooltip": Object { + "format": Object { + "title": [Function], + }, + "grouped": false, + }, + "zoom": Object { + "enabled": true, + }, +} +`; + +exports[`getTimeseriesChartConfig should render an empty line chart by default 1`] = ` +Object { + "axis": Object { + "x": Object { + "tick": Object { + "fit": false, + }, + "type": "timeseries", + }, + }, + "data": Object { + "columns": Array [], + "type": "line", + }, + "id": "my-data", + "subchart": Object { + "show": true, + }, + "tooltip": Object { + "format": Object { + "title": [Function], + }, + "grouped": false, + }, + "zoom": Object { + "enabled": true, + }, +} +`;