mirror of
https://github.com/ansible/awx.git
synced 2026-05-19 14:57:39 -02:30
Adds observability metrics chart
wip tooltip renders with colors and disappears scales y axis properly adds legend without buttlets adds legend data but needs styling adds legend, and cleans up code show help text
This commit is contained in:
@@ -20,6 +20,7 @@ import Login from './screens/Login';
|
|||||||
import { isAuthenticated } from './util/auth';
|
import { isAuthenticated } from './util/auth';
|
||||||
import { getLanguageWithoutRegionCode } from './util/language';
|
import { getLanguageWithoutRegionCode } from './util/language';
|
||||||
import { dynamicActivate, locales } from './i18nLoader';
|
import { dynamicActivate, locales } from './i18nLoader';
|
||||||
|
import ObservabilityMetrics from './screens/ObservabilityMetrics';
|
||||||
|
|
||||||
import getRouteConfig from './routeConfig';
|
import getRouteConfig from './routeConfig';
|
||||||
import SubscriptionEdit from './screens/Setting/Subscription/SubscriptionEdit';
|
import SubscriptionEdit from './screens/Setting/Subscription/SubscriptionEdit';
|
||||||
@@ -87,6 +88,7 @@ function App() {
|
|||||||
const { hash, search, pathname } = useLocation();
|
const { hash, search, pathname } = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<<<<<<< HEAD
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<Background>
|
<Background>
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -108,6 +110,47 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Background>
|
</Background>
|
||||||
|
=======
|
||||||
|
<I18nProvider language={language} catalogs={catalogs}>
|
||||||
|
<I18n>
|
||||||
|
{({ i18n }) => (
|
||||||
|
<Background>
|
||||||
|
<Switch>
|
||||||
|
<Route exact strict path="/*/">
|
||||||
|
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/login">
|
||||||
|
<Login isAuthenticated={isAuthenticated} />
|
||||||
|
</Route>
|
||||||
|
<Route exact path="/">
|
||||||
|
<Redirect to="/home" />
|
||||||
|
</Route>
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
|
||||||
|
<Switch>
|
||||||
|
{getRouteConfig(i18n)
|
||||||
|
.flatMap(({ routes }) => routes)
|
||||||
|
.map(({ path, screen: Screen }) => (
|
||||||
|
<ProtectedRoute key={path} path={path}>
|
||||||
|
<Screen match={match} />
|
||||||
|
</ProtectedRoute>
|
||||||
|
))
|
||||||
|
.concat(
|
||||||
|
<Route exact path="/metrics">
|
||||||
|
<ObservabilityMetrics />
|
||||||
|
</Route>,
|
||||||
|
<ProtectedRoute key="not-found" path="*">
|
||||||
|
<NotFound />
|
||||||
|
</ProtectedRoute>
|
||||||
|
)}
|
||||||
|
</Switch>
|
||||||
|
</AppContainer>
|
||||||
|
</ProtectedRoute>
|
||||||
|
</Switch>
|
||||||
|
</Background>
|
||||||
|
)}
|
||||||
|
</I18n>
|
||||||
|
>>>>>>> Adds observability metrics chart
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import JobTemplates from './models/JobTemplates';
|
|||||||
import Jobs from './models/Jobs';
|
import Jobs from './models/Jobs';
|
||||||
import Labels from './models/Labels';
|
import Labels from './models/Labels';
|
||||||
import Me from './models/Me';
|
import Me from './models/Me';
|
||||||
|
import Metrics from './models/Metrics';
|
||||||
import NotificationTemplates from './models/NotificationTemplates';
|
import NotificationTemplates from './models/NotificationTemplates';
|
||||||
import Notifications from './models/Notifications';
|
import Notifications from './models/Notifications';
|
||||||
import Organizations from './models/Organizations';
|
import Organizations from './models/Organizations';
|
||||||
@@ -64,6 +65,7 @@ const JobTemplatesAPI = new JobTemplates();
|
|||||||
const JobsAPI = new Jobs();
|
const JobsAPI = new Jobs();
|
||||||
const LabelsAPI = new Labels();
|
const LabelsAPI = new Labels();
|
||||||
const MeAPI = new Me();
|
const MeAPI = new Me();
|
||||||
|
const MetricsAPI = new Metrics();
|
||||||
const NotificationTemplatesAPI = new NotificationTemplates();
|
const NotificationTemplatesAPI = new NotificationTemplates();
|
||||||
const NotificationsAPI = new Notifications();
|
const NotificationsAPI = new Notifications();
|
||||||
const OrganizationsAPI = new Organizations();
|
const OrganizationsAPI = new Organizations();
|
||||||
@@ -109,6 +111,7 @@ export {
|
|||||||
JobsAPI,
|
JobsAPI,
|
||||||
LabelsAPI,
|
LabelsAPI,
|
||||||
MeAPI,
|
MeAPI,
|
||||||
|
MetricsAPI,
|
||||||
NotificationTemplatesAPI,
|
NotificationTemplatesAPI,
|
||||||
NotificationsAPI,
|
NotificationsAPI,
|
||||||
OrganizationsAPI,
|
OrganizationsAPI,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Base from '../Base';
|
|||||||
class Metrics extends Base {
|
class Metrics extends Base {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/inventories/';
|
this.baseUrl = '/api/v2/metrics/';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export default Metrics;
|
export default Metrics;
|
||||||
|
|||||||
272
awx/ui_next/src/screens/ObservabilityMetrics/LineChart.jsx
Normal file
272
awx/ui_next/src/screens/ObservabilityMetrics/LineChart.jsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import React, { useEffect, useCallback } from 'react';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { PageContextConsumer } from '@patternfly/react-core';
|
||||||
|
|
||||||
|
function LineChart({ data, i18n, helpText }) {
|
||||||
|
console.log(data, 'data');
|
||||||
|
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;
|
||||||
|
data.map(line =>
|
||||||
|
line.values.forEach(value => {
|
||||||
|
if (smallestY === undefined) {
|
||||||
|
smallestY = value.y;
|
||||||
|
}
|
||||||
|
if (value.y < smallestY) {
|
||||||
|
smallestY = value.y;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const xScale = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.domain(d3.extent(data[0].values, d => d.x))
|
||||||
|
.range([0, width - margin]);
|
||||||
|
|
||||||
|
const yScale = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.domain([smallestY, d3.max(data[0].values, d => d.y)])
|
||||||
|
.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)
|
||||||
|
.append('text')
|
||||||
|
.attr('x', 30)
|
||||||
|
.attr('y', 30)
|
||||||
|
.attr('fill', '#000')
|
||||||
|
.text(i18n._(t`Count`));
|
||||||
|
|
||||||
|
svg
|
||||||
|
.append('g')
|
||||||
|
.attr('class', 'y axis')
|
||||||
|
.call(yAxis)
|
||||||
|
.append('text')
|
||||||
|
.attr('y', -30)
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('transform', 'rotate(-90)')
|
||||||
|
.attr('fill', '#000')
|
||||||
|
.text(i18n._(t`Values`));
|
||||||
|
}, [data, i18n, helpText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draw();
|
||||||
|
}, [count, draw]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleResize() {
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, [draw]);
|
||||||
|
|
||||||
|
return <div id="chart" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withPageContext = Component => {
|
||||||
|
return function contextComponent(props) {
|
||||||
|
return (
|
||||||
|
<PageContextConsumer>
|
||||||
|
{pageContext => <Component {...props} pageContext={pageContext} />}
|
||||||
|
</PageContextConsumer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(withPageContext(LineChart));
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
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 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 ObservabilityMetrics({ 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) {
|
||||||
|
console.log('here');
|
||||||
|
return (
|
||||||
|
<PageSection>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<ContentError error={fetchInitialError || updateError} />;
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<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 an 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 an 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()(ObservabilityMetrics);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
|
import ObservabilityMetrics from './ObservabilityMetrics';
|
||||||
|
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('<ObservabilityMetrics/>', () => {
|
||||||
|
let wrapper;
|
||||||
|
beforeEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<ObservabilityMetrics />);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
test('should mound properly', () => {
|
||||||
|
expect(wrapper.find('ObservabilityMetrics').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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1
awx/ui_next/src/screens/ObservabilityMetrics/index.js
Normal file
1
awx/ui_next/src/screens/ObservabilityMetrics/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './ObservabilityMetrics';
|
||||||
Reference in New Issue
Block a user