diff --git a/awx/ui_next/src/App.jsx b/awx/ui_next/src/App.jsx index 2c1c1abd57..a6f1718a0e 100644 --- a/awx/ui_next/src/App.jsx +++ b/awx/ui_next/src/App.jsx @@ -20,6 +20,7 @@ import Login from './screens/Login'; import { isAuthenticated } from './util/auth'; import { getLanguageWithoutRegionCode } from './util/language'; import { dynamicActivate, locales } from './i18nLoader'; +import Metrics from './screens/Metrics'; import getRouteConfig from './routeConfig'; import SubscriptionEdit from './screens/Setting/Subscription/SubscriptionEdit'; @@ -58,6 +59,9 @@ const AuthorizedRoutes = ({ routeConfig }) => { )) .concat( + + + , diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index d048237b74..ff2d0e6fba 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -20,6 +20,7 @@ import JobTemplates from './models/JobTemplates'; import Jobs from './models/Jobs'; import Labels from './models/Labels'; import Me from './models/Me'; +import Metrics from './models/Metrics'; import NotificationTemplates from './models/NotificationTemplates'; import Notifications from './models/Notifications'; import Organizations from './models/Organizations'; @@ -64,6 +65,7 @@ const JobTemplatesAPI = new JobTemplates(); const JobsAPI = new Jobs(); const LabelsAPI = new Labels(); const MeAPI = new Me(); +const MetricsAPI = new Metrics(); const NotificationTemplatesAPI = new NotificationTemplates(); const NotificationsAPI = new Notifications(); const OrganizationsAPI = new Organizations(); @@ -109,6 +111,7 @@ export { JobsAPI, LabelsAPI, MeAPI, + MetricsAPI, NotificationTemplatesAPI, NotificationsAPI, OrganizationsAPI, diff --git a/awx/ui_next/src/api/models/Metrics.js b/awx/ui_next/src/api/models/Metrics.js index e808d26662..f4451aefd9 100644 --- a/awx/ui_next/src/api/models/Metrics.js +++ b/awx/ui_next/src/api/models/Metrics.js @@ -3,7 +3,7 @@ import Base from '../Base'; class Metrics extends Base { constructor(http) { super(http); - this.baseUrl = '/api/v2/inventories/'; + this.baseUrl = '/api/v2/metrics/'; } } export default Metrics; diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx index b4c702bef2..683381fecd 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx @@ -164,6 +164,7 @@ describe('', () => { }); test('should render deletion error modal', async () => { + jest.setTimeout(5000 * 4); InstanceGroupsAPI.destroy.mockRejectedValue( new Error({ response: { diff --git a/awx/ui_next/src/screens/Metrics/LineChart.jsx b/awx/ui_next/src/screens/Metrics/LineChart.jsx new file mode 100644 index 0000000000..594d77b9da --- /dev/null +++ b/awx/ui_next/src/screens/Metrics/LineChart.jsx @@ -0,0 +1,258 @@ +import React, { useEffect, useCallback } from 'react'; +import * as d3 from 'd3'; + +function LineChart({ data, helpText }) { + const count = data[0]?.values.length; + const draw = useCallback(() => { + const margin = 80; + const getWidth = () => { + let width; + // This is in an a try/catch due to an error from jest. + // Even though the d3.select returns a valid selector with + // style function, it says it is null in the test + try { + width = + parseInt(d3.select(`#chart`).style('width'), 10) - margin || 700; + } catch (error) { + width = 700; + } + + return width; + }; + const width = getWidth(); + const height = 500; + const duration = 250; + const circleRadius = 6; + const circleRadiusHover = 8; + + /* Scale */ + let smallestY; + let largestY; + data.map(line => + line.values.forEach(value => { + if (smallestY === undefined) { + smallestY = value.y; + } + if (value.y < smallestY) { + smallestY = value.y; + } + if (largestY === undefined) { + largestY = smallestY + 10; + } + if (value.y > largestY) { + largestY = value.y; + } + }) + ); + + const xScale = d3 + .scaleLinear() + .domain( + d3.max(data[0].values, d => d.x) > 49 + ? d3.extent(data[0].values, d => d.x) + : [0, 50] + ) + .range([0, width - margin]); + + const yScale = d3 + .scaleLinear() + .domain([smallestY, largestY]) + .range([height - margin, 0]); + + const color = d3.scaleOrdinal(d3.schemeCategory10); + + /* Add SVG */ + d3.selectAll(`#chart > *`).remove(); + + const renderTooltip = d => { + d3.selectAll(`.tooltip > *`).remove(); + + d3.select('#chart') + .append('span') + .attr('class', 'tooltip') + .attr('stroke', 'black') + .attr('fill', 'white') + .style('padding-left', '50px'); + const tooltip = {}; + data.map(datum => { + datum.values.forEach(value => { + if (d.x === value.x) { + tooltip[datum.name] = value.y; + } + }); + return tooltip; + }); + Object.entries(tooltip).forEach(([key, value], i) => { + d3.select('.tooltip') + .append('span') + .attr('class', 'tooltip-text-wrapper') + .append('text') + .attr('class', 'tooltip-text') + .style('color', color(i)) + .style('padding-right', '20px') + .text(`${key}: ${value}`); + }); + }; + const removeTooltip = () => { + d3.select('.tooltip') + .style('cursor', 'none') + .selectAll(`.tooltip > *`) + .remove(); + }; + + // Add legend + d3.selectAll(`.legend > *`).remove(); + const legendContainer = d3 + .select('#chart') + .append('div') + .style('display', 'flex') + .attr('class', 'legend') + .attr('height', '400px') + .attr('width', '500px') + .style('padding-left', '50px'); + + legendContainer + .append('text') + .attr('class', 'legend-title') + .attr('x', '100') + .attr('y', '50') + .text('Legend'); + + legendContainer.data(data, (d, i) => { + if (d?.name) { + const legendItemContainer = legendContainer + .append('div') + .style('display', 'flex') + .attr('id', 'legend-item-container') + .style('padding-left', '20px'); + + legendItemContainer + .append('div') + .style('background-color', color(i)) + .style('height', '8px') + .style('width', '8px') + .style('border-radius', '50%') + .style('padding', '5px') + .style('margin-top', '6px'); + + legendItemContainer + .append('text') + .style('padding-left', '20px') + .text(d.name); + } + }); + + // Add help text to top of chart + + d3.select('#chart') + .append('div') + .attr('class', 'help-text') + .style('padding-left', '50px') + .style('padding-top', '20px') + .text(helpText); + + const svg = d3 + .select('#chart') + .append('svg') + .attr('width', `${width + margin}px`) + .attr('height', `${height + margin}px`) + .append('g') + .attr('transform', `translate(${margin}, ${margin})`); + + /* Add line into SVG */ + const line = d3 + .line() + .curve(d3.curveMonotoneX) + .x(d => xScale(d.x)) + .y(d => yScale(d.y)); + + const lines = svg.append('g'); + + lines + .selectAll('.line-group') + .data(data) + .enter() + .append('g') + .attr('class', 'line-group') + .append('path') + .attr('class', 'line') + .style('fill', 'none') + .attr('d', d => line(d.values)) + .style('stroke', (d, i) => color(i)) + .style('stroke-width', '3px'); + + /* Add circles in the line */ + lines + .selectAll('circle-group') + .data(data) + .enter() + .append('g') + .style('fill', (d, i) => color(i)) + .selectAll('circle') + .data(d => d.values) + .enter() + .append('g') + .attr('class', 'circle') + .on('mouseover', (d, i) => { + if (data.length) { + renderTooltip(d, i); + } + }) + .on('mouseout', () => { + removeTooltip(); + }) + .append('circle') + .attr('cx', d => xScale(d.x)) + .attr('cy', d => yScale(d.y)) + .attr('r', circleRadius) + .on('mouseover', () => { + d3.select(this) + .transition() + .duration(duration) + .attr('r', circleRadiusHover); + }) + .on('mouseout', () => { + d3.select(this) + .transition() + .duration(duration) + .attr('r', circleRadius); + }); + + /* Add Axis into SVG */ + const xAxis = d3 + .axisBottom(xScale) + .ticks(data[0].values.length > 5 ? data[0].values.length : 5); + const yAxis = d3.axisLeft(yScale).ticks(5); + + svg + .append('g') + .attr('class', 'x axis') + .attr('transform', `translate(0, ${height - margin})`) + .call(xAxis); + + svg + .append('g') + .attr('class', 'y axis') + .call(yAxis); + }, [data, helpText]); + + useEffect(() => { + draw(); + }, [count, draw]); + + useEffect(() => { + function handleResize() { + draw(); + } + + window.addEventListener('resize', handleResize); + + handleResize(); + + return () => window.removeEventListener('resize', handleResize); + }, [draw]); + + return
; +} + +export default LineChart; diff --git a/awx/ui_next/src/screens/Metrics/LineChart.test.jsx b/awx/ui_next/src/screens/Metrics/LineChart.test.jsx new file mode 100644 index 0000000000..a19e20072f --- /dev/null +++ b/awx/ui_next/src/screens/Metrics/LineChart.test.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import LineChart from './LineChart'; + +describe('', () => { + test('should render properly', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('LineChart').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Metrics/Metrics.jsx b/awx/ui_next/src/screens/Metrics/Metrics.jsx new file mode 100644 index 0000000000..90baa054ab --- /dev/null +++ b/awx/ui_next/src/screens/Metrics/Metrics.jsx @@ -0,0 +1,243 @@ +import React, { useEffect, useCallback, useState, useRef } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + PageSection, + Card, + CardHeader, + CardBody, + Toolbar, + ToolbarGroup, + ToolbarContent, + ToolbarItem, + Select, + SelectOption, +} from '@patternfly/react-core'; + +import LineChart from './LineChart'; +import { MetricsAPI, InstancesAPI } from '../../api'; +import useRequest from '../../util/useRequest'; +import ContentEmpty from '../../components/ContentEmpty'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; +import ContentError from '../../components/ContentError'; + +let count = [0]; + +// hook thats calls api every 3 seconds to get data +function useInterval(callback, delay, instance, metric) { + const savedCallback = useRef(); + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + useEffect(() => { + function tick() { + count.push(count.length); + if (instance && metric) { + savedCallback.current(); + } + } + + const id = setInterval(tick, delay); + return () => { + clearInterval(id); + }; + }, [callback, delay, instance, metric]); + return { count }; +} +function Metrics({ i18n }) { + const [instanceIsOpen, setInstanceIsOpen] = useState(false); + const [instance, setInstance] = useState(null); + const [metric, setMetric] = useState(null); + const [metricIsOpen, setMetricIsOpen] = useState(false); + const [renderedData, setRenderedData] = useState([]); + const { + result: { instances, metrics }, + error: fetchInitialError, + request: fetchInstances, + } = useRequest( + useCallback(async () => { + const [ + { + data: { results }, + }, + { data: mets }, + ] = await Promise.all([ + InstancesAPI.read(), + MetricsAPI.read({ + subsystemonly: 1, + format: 'json', + }), + ]); + + const metricOptions = Object.keys(mets); + + return { + instances: [...results.map(result => result.hostname), 'All'], + metrics: metricOptions, + }; + }, []), + { instances: [], metrics: [] } + ); + + const { + result: helpText, + error: updateError, + request: fetchData, + } = useRequest( + useCallback(async () => { + const { data } = await MetricsAPI.read({ + subsystemonly: 1, + format: 'json', + node: instance === 'All' ? null : instance, + metric, + }); + + const rendered = renderedData; + const instanceData = Object.values(data); + instanceData.forEach(value => { + value.samples.forEach(sample => { + instances.forEach(i => { + if (i === sample.labels.node) { + const renderedIndex = renderedData.findIndex(rd => rd.name === i); + + if (renderedIndex === -1) { + rendered.push({ + name: i, + values: [ + { + y: sample.value, + x: count.length - 1, + }, + ], + }); + } else if ( + rendered[renderedIndex].values?.length === 0 || + !rendered[renderedIndex].values + ) { + rendered[renderedIndex].values = [ + { y: sample.value, x: count.length - 1 }, + ]; + } else { + rendered[renderedIndex].values = [ + ...rendered[renderedIndex].values, + { y: sample.value, x: count.length - 1 }, + ]; + } + } + }); + }); + }); + let countRestrictedData = rendered; + if (count.length > 49) { + countRestrictedData = rendered.map(({ values, name }) => ({ + name, + values: values.slice(-50), + })); + } + + setRenderedData(countRestrictedData); + return data[metric].help_text; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [instance, metric, instances]), + '' + ); + + useInterval(fetchData, 3000, instance, metric); + + useEffect(() => { + if (instance && metric) { + fetchData(); + } + }, [fetchData, instance, metric]); + + useEffect(() => { + fetchInstances(); + }, [fetchInstances]); + if (fetchInitialError || updateError) { + return ( + + + + ; + + + + ); + } + return ( + <> + + + + + + + + + {i18n._(t`Instance`)} + + + + {i18n._(t`Metric`)} + + + + + + + + + {instance && metric ? ( + Object.keys(renderedData).length > 0 && ( + + ) + ) : ( + + )} + + + + + ); +} + +export default withI18n()(Metrics); diff --git a/awx/ui_next/src/screens/Metrics/Metrics.test.jsx b/awx/ui_next/src/screens/Metrics/Metrics.test.jsx new file mode 100644 index 0000000000..fb74d6d929 --- /dev/null +++ b/awx/ui_next/src/screens/Metrics/Metrics.test.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import Metrics from './Metrics'; +import { MetricsAPI, InstancesAPI } from '../../api'; + +jest.mock('../../api/models/Instances'); +jest.mock('../../api/models/Metrics'); +InstancesAPI.read.mockResolvedValue({ + data: { results: [{ hostname: 'instance 1' }, { hostname: 'instance 2' }] }, +}); +MetricsAPI.read.mockResolvedValue({ + data: { + metric1: { + helptext: 'metric 1 help text', + samples: [{ labels: { node: 'metric 1' }, value: 20 }], + }, + metric2: { + helptext: 'metric 2 help text', + samples: [{ labels: { node: 'metric 2' }, value: 10 }], + }, + }, +}); + +describe('', () => { + let wrapper; + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + }); + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + test('should mound properly', () => { + expect(wrapper.find('Metrics').length).toBe(1); + expect(wrapper.find('EmptyStateBody').length).toBe(1); + expect(wrapper.find('ChartLine').length).toBe(0); + }); + test('should render chart after selecting metric and instance', async () => { + await act(async () => { + wrapper.find('Select[ouiaId="Instance-select"]').prop('onToggle')(true); + }); + wrapper.update(); + await act(async () => { + wrapper + .find('SelectOption[value="instance 1"]') + .find('button') + .prop('onClick')({}, 'instance 1'); + }); + wrapper.update(); + await act(() => { + wrapper.find('Select[ouiaId="Metric-select"]').prop('onToggle')(true); + }); + wrapper.update(); + await act(async () => { + wrapper + .find('SelectOption[value="metric1"]') + .find('button') + .prop('onClick')({}, 'metric1'); + }); + wrapper.update(); + expect(MetricsAPI.read).toBeCalledWith({ + subsystemonly: 1, + format: 'json', + metric: 'metric1', + node: 'instance 1', + }); + }); +}); diff --git a/awx/ui_next/src/screens/Metrics/index.js b/awx/ui_next/src/screens/Metrics/index.js new file mode 100644 index 0000000000..8e5880233f --- /dev/null +++ b/awx/ui_next/src/screens/Metrics/index.js @@ -0,0 +1 @@ +export { default } from './Metrics';