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';