Merge pull request #9776 from AlexSCorey/9019-MultiChart

Observability Metrics

SUMMARY
This adds the chart for Observability Metrics (#9019).  To see the chart you need to navigate to /metrics.  Also, its best if you run a build that has multiple instances.  This will do that for you COMPOSE_TAG=devel CLUSTER_NODE_COUNT=2 make docker-compose.
When this feature loads the user has to select an instance (1, or all) and a metric to render data on the graph.  Once they select those items, the chart appears and we start to make requests to the api every 3 seconds to get the data. (Currently the api does not support web sockets for this feature) If the user changes the values for either of the drop down items the chart resets.  The chart also only show the last 50 data points.
There is a "tooltip" that is rendered at the bottom left hand side.  I decided to put it there, instead of on the chart itself because this chart could get quite crowded depending the number of data points rendered and the number of instances rendering lines.
The X axis is sort of meaningless.  The values below simply render the number of api requests. This isn't a value of time. Since the main goal of this feature is to show significant changes instead of tryin to pinpoint when the change occurs I felt that showing a time stamp on this axis would crowd the axis as well.
ISSUE TYPE

Feature Pull Request

COMPONENT NAME

UI

AWX VERSION
ADDITIONAL INFORMATION

Reviewed-by: Kersom <None>
Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
Reviewed-by: Alex Corey <Alex.swansboro@gmail.com>
Reviewed-by: Mat Wilson <mawilson@redhat.com>
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-04-15 21:59:28 +00:00 committed by GitHub
commit d53d41b84a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 620 additions and 1 deletions

View File

@ -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 }) => {
</ProtectedRoute>
))
.concat(
<ProtectedRoute key="metrics" path="/metrics">
<Metrics />
</ProtectedRoute>,
<ProtectedRoute key="not-found" path="*">
<NotFound />
</ProtectedRoute>

View File

@ -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,

View File

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

View File

@ -164,6 +164,7 @@ describe('<InstanceGroupList />', () => {
});
test('should render deletion error modal', async () => {
jest.setTimeout(5000 * 4);
InstanceGroupsAPI.destroy.mockRejectedValue(
new Error({
response: {

View File

@ -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 <div id="chart" />;
}
export default LineChart;

View File

@ -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('<LineChart/>', () => {
test('should render properly', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<LineChart
data={[
{
name: 'Instance 1',
values: [
{ x: 0, y: 10 },
{ x: 1, y: 20 },
{ x: 3, y: 30 },
],
},
{
name: 'Instance 1',
values: [
{ x: 0, y: 40 },
{ x: 1, y: 50 },
{ x: 3, y: 60 },
],
},
]}
helpText="This is the help text"
/>
);
});
expect(wrapper.find('LineChart').length).toBe(1);
});
});

View File

@ -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 (
<PageSection>
<Card>
<CardBody>
<ContentError error={fetchInitialError || updateError} />;
</CardBody>
</Card>
</PageSection>
);
}
return (
<>
<ScreenHeader breadcrumbConfig={{ '/metrics': i18n._(t`Metrics`) }} />
<PageSection>
<Card>
<CardHeader>
<Toolbar>
<ToolbarContent>
<ToolbarGroup>
<ToolbarItem>{i18n._(t`Instance`)}</ToolbarItem>
<ToolbarItem>
<Select
ouiaId="Instance-select"
onToggle={setInstanceIsOpen}
isOpen={instanceIsOpen}
onSelect={(e, value) => {
count = [0];
setInstance(value);
setInstanceIsOpen(false);
setRenderedData([]);
}}
selections={instance}
placeholderText={i18n._(t`Select a instance`)}
>
{instances.map(inst => (
<SelectOption value={inst} key={inst} />
))}
</Select>
</ToolbarItem>
<ToolbarItem>{i18n._(t`Metric`)}</ToolbarItem>
<ToolbarItem>
<Select
ouiaId="Metric-select"
placeholderText={i18n._(t`Select a metric`)}
isOpen={metricIsOpen}
onSelect={(e, value) => {
count = [0];
setMetric(value);
setRenderedData([]);
setMetricIsOpen(false);
}}
onToggle={setMetricIsOpen}
selections={metric}
>
{metrics.map(met => (
<SelectOption value={met} key={met} />
))}
</Select>
</ToolbarItem>
</ToolbarGroup>
</ToolbarContent>
</Toolbar>
</CardHeader>
<CardBody>
{instance && metric ? (
Object.keys(renderedData).length > 0 && (
<LineChart
data={renderedData}
count={count}
helpText={helpText}
/>
)
) : (
<ContentEmpty
title={i18n._(t`Select an instance and a metric to show chart`)}
/>
)}
</CardBody>
</Card>
</PageSection>
</>
);
}
export default withI18n()(Metrics);

View File

@ -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('<Metrics/>', () => {
let wrapper;
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(<Metrics />);
});
});
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',
});
});
});

View File

@ -0,0 +1 @@
export { default } from './Metrics';