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:
Alex Corey
2021-03-26 12:22:02 -04:00
parent 98bb296c6a
commit 51257a2f62
8 changed files with 668 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 ObservabilityMetrics from './screens/ObservabilityMetrics';
import getRouteConfig from './routeConfig';
import SubscriptionEdit from './screens/Setting/Subscription/SubscriptionEdit';
@@ -87,6 +88,7 @@ function App() {
const { hash, search, pathname } = useLocation();
return (
<<<<<<< HEAD
<I18nProvider i18n={i18n}>
<Background>
<Switch>
@@ -108,6 +110,47 @@ function App() {
</ProtectedRoute>
</Switch>
</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>
);
}

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

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

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,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);

View File

@@ -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',
});
});
});

View File

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