mirror of
https://github.com/ansible/awx.git
synced 2026-01-13 19:10:07 -03:30
add subscription usage page
This commit is contained in:
parent
bb3acbb8ad
commit
6e06a20cca
@ -33,6 +33,7 @@ import Roles from './models/Roles';
|
||||
import Root from './models/Root';
|
||||
import Schedules from './models/Schedules';
|
||||
import Settings from './models/Settings';
|
||||
import SubscriptionUsage from './models/SubscriptionUsage';
|
||||
import SystemJobs from './models/SystemJobs';
|
||||
import SystemJobTemplates from './models/SystemJobTemplates';
|
||||
import Teams from './models/Teams';
|
||||
@ -82,6 +83,7 @@ const RolesAPI = new Roles();
|
||||
const RootAPI = new Root();
|
||||
const SchedulesAPI = new Schedules();
|
||||
const SettingsAPI = new Settings();
|
||||
const SubscriptionUsageAPI = new SubscriptionUsage();
|
||||
const SystemJobsAPI = new SystemJobs();
|
||||
const SystemJobTemplatesAPI = new SystemJobTemplates();
|
||||
const TeamsAPI = new Teams();
|
||||
@ -132,6 +134,7 @@ export {
|
||||
RootAPI,
|
||||
SchedulesAPI,
|
||||
SettingsAPI,
|
||||
SubscriptionUsageAPI,
|
||||
SystemJobsAPI,
|
||||
SystemJobTemplatesAPI,
|
||||
TeamsAPI,
|
||||
|
||||
16
awx/ui/src/api/models/SubscriptionUsage.js
Normal file
16
awx/ui/src/api/models/SubscriptionUsage.js
Normal file
@ -0,0 +1,16 @@
|
||||
import Base from '../Base';
|
||||
|
||||
class SubscriptionUsage extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = 'api/v2/host_metric_summary_monthly/';
|
||||
}
|
||||
|
||||
readSubscriptionUsageChart(dateRange) {
|
||||
return this.http.get(
|
||||
`${this.baseUrl}?date__gte=${dateRange}&order_by=date&page_size=100`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SubscriptionUsage;
|
||||
@ -17,6 +17,7 @@ import Organizations from 'screens/Organization';
|
||||
import Projects from 'screens/Project';
|
||||
import Schedules from 'screens/Schedule';
|
||||
import Settings from 'screens/Setting';
|
||||
import SubscriptionUsage from 'screens/SubscriptionUsage/SubscriptionUsage';
|
||||
import Teams from 'screens/Team';
|
||||
import Templates from 'screens/Template';
|
||||
import TopologyView from 'screens/TopologyView';
|
||||
@ -61,6 +62,11 @@ function getRouteConfig(userProfile = {}) {
|
||||
path: '/host_metrics',
|
||||
screen: HostMetrics,
|
||||
},
|
||||
{
|
||||
title: <Trans>Subscription Usage</Trans>,
|
||||
path: '/subscription_usage',
|
||||
screen: SubscriptionUsage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -189,6 +195,7 @@ function getRouteConfig(userProfile = {}) {
|
||||
'unique_managed_hosts'
|
||||
) {
|
||||
deleteRoute('host_metrics');
|
||||
deleteRoute('subscription_usage');
|
||||
}
|
||||
if (userProfile?.isSuperUser || userProfile?.isSystemAuditor)
|
||||
return routeConfig;
|
||||
@ -197,6 +204,7 @@ function getRouteConfig(userProfile = {}) {
|
||||
deleteRoute('management_jobs');
|
||||
deleteRoute('topology_view');
|
||||
deleteRoute('instances');
|
||||
deleteRoute('subscription_usage');
|
||||
if (userProfile?.isOrgAdmin) return routeConfig;
|
||||
if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates');
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ describe('getRouteConfig', () => {
|
||||
'/activity_stream',
|
||||
'/workflow_approvals',
|
||||
'/host_metrics',
|
||||
'/subscription_usage',
|
||||
'/templates',
|
||||
'/credentials',
|
||||
'/projects',
|
||||
@ -61,6 +62,7 @@ describe('getRouteConfig', () => {
|
||||
'/activity_stream',
|
||||
'/workflow_approvals',
|
||||
'/host_metrics',
|
||||
'/subscription_usage',
|
||||
'/templates',
|
||||
'/credentials',
|
||||
'/projects',
|
||||
|
||||
@ -0,0 +1,319 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { string, number, shape, arrayOf } from 'prop-types';
|
||||
import * as d3 from 'd3';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageContextConsumer } from '@patternfly/react-core';
|
||||
import UsageChartTooltip from './UsageChartTooltip';
|
||||
|
||||
function UsageChart({ id, data, height, pageContext }) {
|
||||
const { isNavOpen } = pageContext;
|
||||
|
||||
// Methods
|
||||
const draw = useCallback(() => {
|
||||
const margin = { top: 15, right: 25, bottom: 105, left: 70 };
|
||||
|
||||
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(`#${id}`).style('width'), 10) -
|
||||
margin.left -
|
||||
margin.right || 700;
|
||||
} catch (error) {
|
||||
width = 700;
|
||||
}
|
||||
return width;
|
||||
};
|
||||
|
||||
// Clear our chart container element first
|
||||
d3.selectAll(`#${id} > *`).remove();
|
||||
const width = getWidth();
|
||||
|
||||
function transition(path) {
|
||||
path.transition().duration(1000).attrTween('stroke-dasharray', tweenDash);
|
||||
}
|
||||
|
||||
function tweenDash(...params) {
|
||||
const l = params[2][params[1]].getTotalLength();
|
||||
const i = d3.interpolateString(`0,${l}`, `${l},${l}`);
|
||||
return (val) => i(val);
|
||||
}
|
||||
|
||||
const x = d3.scaleTime().rangeRound([0, width]);
|
||||
const y = d3.scaleLinear().range([height, 0]);
|
||||
|
||||
// [consumed, capacity]
|
||||
const colors = d3.scaleOrdinal(['#06C', '#C9190B']);
|
||||
const svg = d3
|
||||
.select(`#${id}`)
|
||||
.append('svg')
|
||||
.attr('width', width + margin.left + margin.right)
|
||||
.attr('height', height + margin.top + margin.bottom)
|
||||
.attr('z', 100)
|
||||
.append('g')
|
||||
.attr('id', 'chart-container')
|
||||
.attr('transform', `translate(${margin.left}, ${margin.top})`);
|
||||
// Tooltip
|
||||
const tooltip = new UsageChartTooltip({
|
||||
svg: `#${id}`,
|
||||
colors,
|
||||
label: t`Hosts`,
|
||||
});
|
||||
|
||||
const parseTime = d3.timeParse('%Y-%m-%d');
|
||||
|
||||
const formattedData = data?.reduce(
|
||||
(formatted, { date, license_consumed, license_capacity }) => {
|
||||
const MONTH = parseTime(date);
|
||||
const CONSUMED = +license_consumed;
|
||||
const CAPACITY = +license_capacity;
|
||||
return formatted.concat({ MONTH, CONSUMED, CAPACITY });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Scale the range of the data
|
||||
const largestY = formattedData?.reduce((a_max, b) => {
|
||||
const b_max = Math.max(b.CONSUMED > b.CAPACITY ? b.CONSUMED : b.CAPACITY);
|
||||
return a_max > b_max ? a_max : b_max;
|
||||
}, 0);
|
||||
x.domain(d3.extent(formattedData, (d) => d.MONTH));
|
||||
y.domain([
|
||||
0,
|
||||
largestY > 4 ? largestY + Math.max(largestY / 10, 1) : 5,
|
||||
]).nice();
|
||||
|
||||
const capacityLine = d3
|
||||
.line()
|
||||
.curve(d3.curveMonotoneX)
|
||||
.x((d) => x(d.MONTH))
|
||||
.y((d) => y(d.CAPACITY));
|
||||
|
||||
const consumedLine = d3
|
||||
.line()
|
||||
.curve(d3.curveMonotoneX)
|
||||
.x((d) => x(d.MONTH))
|
||||
.y((d) => y(d.CONSUMED));
|
||||
|
||||
// Add the Y Axis
|
||||
svg
|
||||
.append('g')
|
||||
.attr('class', 'y-axis')
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.ticks(
|
||||
largestY > 3
|
||||
? Math.min(largestY + Math.max(largestY / 10, 1), 10)
|
||||
: 5
|
||||
)
|
||||
.tickSize(-width)
|
||||
.tickFormat(d3.format('d'))
|
||||
)
|
||||
.selectAll('line')
|
||||
.attr('stroke', '#d7d7d7');
|
||||
svg.selectAll('.y-axis .tick text').attr('x', -5).attr('font-size', '14');
|
||||
|
||||
// text label for the y axis
|
||||
svg
|
||||
.append('text')
|
||||
.attr('transform', 'rotate(-90)')
|
||||
.attr('y', 0 - margin.left)
|
||||
.attr('x', 0 - height / 2)
|
||||
.attr('dy', '1em')
|
||||
.style('text-anchor', 'middle')
|
||||
.text(t`Unique Hosts`);
|
||||
|
||||
// Add the X Axis
|
||||
let ticks;
|
||||
const maxTicks = Math.round(
|
||||
formattedData.length / (formattedData.length / 2)
|
||||
);
|
||||
ticks = formattedData.map((d) => d.MONTH);
|
||||
if (formattedData.length === 13) {
|
||||
ticks = formattedData
|
||||
.map((d, i) => (i % maxTicks === 0 ? d.MONTH : undefined))
|
||||
.filter((item) => item);
|
||||
}
|
||||
|
||||
svg.select('.domain').attr('stroke', '#d7d7d7');
|
||||
|
||||
svg
|
||||
.append('g')
|
||||
.attr('class', 'x-axis')
|
||||
.attr('transform', `translate(0, ${height})`)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.tickValues(ticks)
|
||||
.tickSize(-height)
|
||||
.tickFormat(d3.timeFormat('%m/%y'))
|
||||
)
|
||||
.selectAll('line')
|
||||
.attr('stroke', '#d7d7d7');
|
||||
|
||||
svg
|
||||
.selectAll('.x-axis .tick text')
|
||||
.attr('x', -25)
|
||||
.attr('font-size', '14')
|
||||
.attr('transform', 'rotate(-65)');
|
||||
|
||||
// text label for the x axis
|
||||
svg
|
||||
.append('text')
|
||||
.attr(
|
||||
'transform',
|
||||
`translate(${width / 2} , ${height + margin.top + 50})`
|
||||
)
|
||||
.style('text-anchor', 'middle')
|
||||
.text(t`Month`);
|
||||
const vertical = svg
|
||||
.append('path')
|
||||
.attr('class', 'mouse-line')
|
||||
.style('stroke', 'black')
|
||||
.style('stroke-width', '3px')
|
||||
.style('stroke-dasharray', '3, 3')
|
||||
.style('opacity', '0');
|
||||
|
||||
const handleMouseOver = (event, d) => {
|
||||
tooltip.handleMouseOver(event, d);
|
||||
// show vertical line
|
||||
vertical.transition().style('opacity', '1');
|
||||
};
|
||||
const handleMouseMove = function mouseMove(event) {
|
||||
const [pointerX] = d3.pointer(event);
|
||||
vertical.attr('d', () => `M${pointerX},${height} ${pointerX},${0}`);
|
||||
};
|
||||
|
||||
const handleMouseOut = () => {
|
||||
// hide tooltip
|
||||
tooltip.handleMouseOut();
|
||||
// hide vertical line
|
||||
vertical.transition().style('opacity', 0);
|
||||
};
|
||||
|
||||
const dateFormat = d3.timeFormat('%m/%y');
|
||||
|
||||
// Add the consumed line path
|
||||
svg
|
||||
.append('path')
|
||||
.data([formattedData])
|
||||
.attr('class', 'line')
|
||||
.style('fill', 'none')
|
||||
.style('stroke', () => colors(1))
|
||||
.attr('stroke-width', 2)
|
||||
.attr('d', consumedLine)
|
||||
.call(transition);
|
||||
|
||||
// create our consumed line circles
|
||||
|
||||
svg
|
||||
.selectAll('dot')
|
||||
.data(formattedData)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('r', 3)
|
||||
.style('stroke', () => colors(1))
|
||||
.style('fill', () => colors(1))
|
||||
.attr('cx', (d) => x(d.MONTH))
|
||||
.attr('cy', (d) => y(d.CONSUMED))
|
||||
.attr('id', (d) => `consumed-dot-${dateFormat(d.MONTH)}`)
|
||||
.on('mouseover', (event, d) => handleMouseOver(event, d))
|
||||
.on('mousemove', handleMouseMove)
|
||||
.on('mouseout', handleMouseOut);
|
||||
|
||||
// Add the capacity line path
|
||||
svg
|
||||
.append('path')
|
||||
.data([formattedData])
|
||||
.attr('class', 'line')
|
||||
.style('fill', 'none')
|
||||
.style('stroke', () => colors(0))
|
||||
.attr('stroke-width', 2)
|
||||
.attr('d', capacityLine)
|
||||
.call(transition);
|
||||
|
||||
// create our capacity line circles
|
||||
|
||||
svg
|
||||
.selectAll('dot')
|
||||
.data(formattedData)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('r', 3)
|
||||
.style('stroke', () => colors(0))
|
||||
.style('fill', () => colors(0))
|
||||
.attr('cx', (d) => x(d.MONTH))
|
||||
.attr('cy', (d) => y(d.CAPACITY))
|
||||
.attr('id', (d) => `capacity-dot-${dateFormat(d.MONTH)}`)
|
||||
.on('mouseover', handleMouseOver)
|
||||
.on('mousemove', handleMouseMove)
|
||||
.on('mouseout', handleMouseOut);
|
||||
|
||||
// Create legend
|
||||
const legend_keys = [t`Subscriptions consumed`, t`Subscription capacity`];
|
||||
let totalWidth = width / 2 - 175;
|
||||
|
||||
const lineLegend = svg
|
||||
.selectAll('.lineLegend')
|
||||
.data(legend_keys)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'lineLegend')
|
||||
.each(function formatLegend() {
|
||||
const current = d3.select(this);
|
||||
current.attr('transform', `translate(${totalWidth}, ${height + 90})`);
|
||||
totalWidth += 200;
|
||||
});
|
||||
|
||||
lineLegend
|
||||
.append('text')
|
||||
.text((d) => d)
|
||||
.attr('font-size', '14')
|
||||
.attr('transform', 'translate(15,9)'); // align texts with boxes
|
||||
|
||||
lineLegend
|
||||
.append('rect')
|
||||
.attr('fill', (d) => colors(d))
|
||||
.attr('width', 10)
|
||||
.attr('height', 10);
|
||||
}, [data, height, id]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw, isNavOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
draw();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
handleResize();
|
||||
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [draw]);
|
||||
|
||||
return <div id={id} />;
|
||||
}
|
||||
|
||||
UsageChart.propTypes = {
|
||||
id: string.isRequired,
|
||||
data: arrayOf(shape({})).isRequired,
|
||||
height: number.isRequired,
|
||||
};
|
||||
|
||||
const withPageContext = (Component) =>
|
||||
function contextComponent(props) {
|
||||
return (
|
||||
<PageContextConsumer>
|
||||
{(pageContext) => <Component {...props} pageContext={pageContext} />}
|
||||
</PageContextConsumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default withPageContext(UsageChart);
|
||||
@ -0,0 +1,177 @@
|
||||
import * as d3 from 'd3';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
class UsageChartTooltip {
|
||||
constructor(opts) {
|
||||
this.label = opts.label;
|
||||
this.svg = opts.svg;
|
||||
this.colors = opts.colors;
|
||||
|
||||
this.draw();
|
||||
}
|
||||
|
||||
draw() {
|
||||
this.toolTipBase = d3.select(`${this.svg} > svg`).append('g');
|
||||
this.toolTipBase.attr('id', 'chart-tooltip');
|
||||
this.toolTipBase.attr('overflow', 'visible');
|
||||
this.toolTipBase.style('opacity', 0);
|
||||
this.toolTipBase.style('pointer-events', 'none');
|
||||
this.toolTipBase.attr('transform', 'translate(100, 100)');
|
||||
this.boxWidth = 200;
|
||||
this.textWidthThreshold = 20;
|
||||
|
||||
this.toolTipPoint = this.toolTipBase
|
||||
.append('rect')
|
||||
.attr('transform', 'translate(10, -10) rotate(45)')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('height', 20)
|
||||
.attr('width', 20)
|
||||
.attr('fill', '#393f44');
|
||||
this.boundingBox = this.toolTipBase
|
||||
.append('rect')
|
||||
.attr('x', 10)
|
||||
.attr('y', -41)
|
||||
.attr('rx', 2)
|
||||
.attr('height', 82)
|
||||
.attr('width', this.boxWidth)
|
||||
.attr('fill', '#393f44');
|
||||
this.circleBlue = this.toolTipBase
|
||||
.append('circle')
|
||||
.attr('cx', 26)
|
||||
.attr('cy', 0)
|
||||
.attr('r', 7)
|
||||
.attr('stroke', 'white')
|
||||
.attr('fill', this.colors(1));
|
||||
this.circleRed = this.toolTipBase
|
||||
.append('circle')
|
||||
.attr('cx', 26)
|
||||
.attr('cy', 26)
|
||||
.attr('r', 7)
|
||||
.attr('stroke', 'white')
|
||||
.attr('fill', this.colors(0));
|
||||
this.consumedText = this.toolTipBase
|
||||
.append('text')
|
||||
.attr('x', 43)
|
||||
.attr('y', 4)
|
||||
.attr('font-size', 12)
|
||||
.attr('fill', 'white')
|
||||
.text(t`Subscriptions consumed`);
|
||||
this.capacityText = this.toolTipBase
|
||||
.append('text')
|
||||
.attr('x', 43)
|
||||
.attr('y', 28)
|
||||
.attr('font-size', 12)
|
||||
.attr('fill', 'white')
|
||||
.text(t`Subscription capacity`);
|
||||
this.icon = this.toolTipBase
|
||||
.append('text')
|
||||
.attr('fill', 'white')
|
||||
.attr('stroke', 'white')
|
||||
.attr('x', 24)
|
||||
.attr('y', 30)
|
||||
.attr('font-size', 12);
|
||||
this.consumed = this.toolTipBase
|
||||
.append('text')
|
||||
.attr('fill', 'white')
|
||||
.attr('font-size', 12)
|
||||
.attr('x', 122)
|
||||
.attr('y', 4)
|
||||
.attr('id', 'consumed-count')
|
||||
.text('0');
|
||||
this.capacity = this.toolTipBase
|
||||
.append('text')
|
||||
.attr('fill', 'white')
|
||||
.attr('font-size', 12)
|
||||
.attr('x', 122)
|
||||
.attr('y', 28)
|
||||
.attr('id', 'capacity-count')
|
||||
.text('0');
|
||||
this.date = this.toolTipBase
|
||||
.append('text')
|
||||
.attr('fill', 'white')
|
||||
.attr('stroke', 'white')
|
||||
.attr('x', 20)
|
||||
.attr('y', -21)
|
||||
.attr('font-size', 12);
|
||||
}
|
||||
|
||||
handleMouseOver = (event, data) => {
|
||||
let consumed = 0;
|
||||
let capacity = 0;
|
||||
const [x, y] = d3.pointer(event);
|
||||
const tooltipPointerX = x + 75;
|
||||
|
||||
const formatTooltipDate = d3.timeFormat('%m/%y');
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolTipWidth = this.toolTipBase.node().getBoundingClientRect().width;
|
||||
const chartWidth = d3
|
||||
.select(`${this.svg}> svg`)
|
||||
.node()
|
||||
.getBoundingClientRect().width;
|
||||
const overflow = 100 - (toolTipWidth / chartWidth) * 100;
|
||||
const flipped = overflow < (tooltipPointerX / chartWidth) * 100;
|
||||
if (data) {
|
||||
consumed = data.CONSUMED || 0;
|
||||
capacity = data.CAPACITY || 0;
|
||||
this.date.text(formatTooltipDate(data.MONTH || null));
|
||||
}
|
||||
|
||||
this.capacity.text(`${capacity}`);
|
||||
this.consumed.text(`${consumed}`);
|
||||
this.consumedTextWidth = this.consumed.node().getComputedTextLength();
|
||||
this.capacityTextWidth = this.capacity.node().getComputedTextLength();
|
||||
|
||||
const maxTextPerc = (this.jobsWidth / this.boxWidth) * 100;
|
||||
const threshold = 40;
|
||||
const overage = maxTextPerc / threshold;
|
||||
let adjustedWidth;
|
||||
if (maxTextPerc > threshold) {
|
||||
adjustedWidth = this.boxWidth * overage;
|
||||
} else {
|
||||
adjustedWidth = this.boxWidth;
|
||||
}
|
||||
|
||||
this.boundingBox.attr('width', adjustedWidth);
|
||||
this.toolTipBase.attr('transform', `translate(${tooltipPointerX}, ${y})`);
|
||||
if (flipped) {
|
||||
this.toolTipPoint.attr('transform', 'translate(-20, -10) rotate(45)');
|
||||
this.boundingBox.attr('x', -adjustedWidth - 20);
|
||||
this.circleBlue.attr('cx', -adjustedWidth);
|
||||
this.circleRed.attr('cx', -adjustedWidth);
|
||||
this.icon.attr('x', -adjustedWidth - 2);
|
||||
this.consumedText.attr('x', -adjustedWidth + 17);
|
||||
this.capacityText.attr('x', -adjustedWidth + 17);
|
||||
this.consumed.attr('x', -this.consumedTextWidth - 20 - 12);
|
||||
this.capacity.attr('x', -this.capacityTextWidth - 20 - 12);
|
||||
this.date.attr('x', -adjustedWidth - 5);
|
||||
} else {
|
||||
this.toolTipPoint.attr('transform', 'translate(10, -10) rotate(45)');
|
||||
this.boundingBox.attr('x', 10);
|
||||
this.circleBlue.attr('cx', 26);
|
||||
this.circleRed.attr('cx', 26);
|
||||
this.icon.attr('x', 24);
|
||||
this.consumedText.attr('x', 43);
|
||||
this.capacityText.attr('x', 43);
|
||||
this.consumed.attr('x', adjustedWidth - this.consumedTextWidth);
|
||||
this.capacity.attr('x', adjustedWidth - this.capacityTextWidth);
|
||||
this.date.attr('x', 20);
|
||||
}
|
||||
|
||||
this.toolTipBase.style('opacity', 1);
|
||||
this.toolTipBase.interrupt();
|
||||
};
|
||||
|
||||
handleMouseOut = () => {
|
||||
this.toolTipBase
|
||||
.transition()
|
||||
.delay(15)
|
||||
.style('opacity', 0)
|
||||
.style('pointer-events', 'none');
|
||||
};
|
||||
}
|
||||
|
||||
export default UsageChartTooltip;
|
||||
53
awx/ui/src/screens/SubscriptionUsage/SubscriptionUsage.js
Normal file
53
awx/ui/src/screens/SubscriptionUsage/SubscriptionUsage.js
Normal file
@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { Banner, Card, PageSection } from '@patternfly/react-core';
|
||||
import { InfoCircleIcon } from '@patternfly/react-icons';
|
||||
|
||||
import { useConfig } from 'contexts/Config';
|
||||
import useBrandName from 'hooks/useBrandName';
|
||||
import ScreenHeader from 'components/ScreenHeader';
|
||||
import SubscriptionUsageChart from './SubscriptionUsageChart';
|
||||
|
||||
const MainPageSection = styled(PageSection)`
|
||||
padding-top: 24px;
|
||||
padding-bottom: 0;
|
||||
|
||||
& .spacer {
|
||||
margin-bottom: var(--pf-global--spacer--lg);
|
||||
}
|
||||
`;
|
||||
|
||||
function SubscriptionUsage() {
|
||||
const config = useConfig();
|
||||
const brandName = useBrandName();
|
||||
|
||||
return (
|
||||
<>
|
||||
{config?.ui_next && (
|
||||
<Banner variant="info">
|
||||
<Trans>
|
||||
<p>
|
||||
<InfoCircleIcon /> A tech preview of the new {brandName} user
|
||||
interface can be found <a href="/ui_next/dashboard">here</a>.
|
||||
</p>
|
||||
</Trans>
|
||||
</Banner>
|
||||
)}
|
||||
<ScreenHeader
|
||||
streamType="all"
|
||||
breadcrumbConfig={{ '/subscription_usage': t`Subscription Usage` }}
|
||||
/>
|
||||
<MainPageSection>
|
||||
<div className="spacer">
|
||||
<Card id="dashboard-main-container">
|
||||
<SubscriptionUsageChart />
|
||||
</Card>
|
||||
</div>
|
||||
</MainPageSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SubscriptionUsage;
|
||||
167
awx/ui/src/screens/SubscriptionUsage/SubscriptionUsageChart.js
Normal file
167
awx/ui/src/screens/SubscriptionUsage/SubscriptionUsageChart.js
Normal file
@ -0,0 +1,167 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardActions,
|
||||
CardBody,
|
||||
CardTitle,
|
||||
Flex,
|
||||
FlexItem,
|
||||
PageSection,
|
||||
Select,
|
||||
SelectVariant,
|
||||
SelectOption,
|
||||
Text,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
import useRequest from 'hooks/useRequest';
|
||||
import { SubscriptionUsageAPI } from 'api';
|
||||
import { useUserProfile } from 'contexts/Config';
|
||||
import ContentLoading from 'components/ContentLoading';
|
||||
import UsageChart from './ChartComponents/UsageChart';
|
||||
|
||||
const GraphCardHeader = styled(CardHeader)`
|
||||
margin-bottom: var(--pf-global--spacer--lg);
|
||||
`;
|
||||
|
||||
const ChartCardTitle = styled(CardTitle)`
|
||||
padding-right: 24px;
|
||||
font-size: 20px;
|
||||
font-weight: var(--pf-c-title--m-xl--FontWeight);
|
||||
`;
|
||||
|
||||
const CardText = styled(Text)`
|
||||
padding-right: 24px;
|
||||
`;
|
||||
|
||||
const GraphCardActions = styled(CardActions)`
|
||||
margin-left: initial;
|
||||
padding-left: 0;
|
||||
`;
|
||||
|
||||
function SubscriptionUsageChart() {
|
||||
const [isPeriodDropdownOpen, setIsPeriodDropdownOpen] = useState(false);
|
||||
const [periodSelection, setPeriodSelection] = useState('year');
|
||||
const userProfile = useUserProfile();
|
||||
|
||||
const calculateDateRange = () => {
|
||||
const today = new Date();
|
||||
let date = '';
|
||||
switch (periodSelection) {
|
||||
case 'year':
|
||||
date =
|
||||
today.getMonth() < 10
|
||||
? `${today.getFullYear() - 1}-0${today.getMonth() + 1}-01`
|
||||
: `${today.getFullYear() - 1}-${today.getMonth() + 1}-01`;
|
||||
break;
|
||||
case 'two_years':
|
||||
date =
|
||||
today.getMonth() < 10
|
||||
? `${today.getFullYear() - 2}-0${today.getMonth() + 1}-01`
|
||||
: `${today.getFullYear() - 2}-${today.getMonth() + 1}-01`;
|
||||
break;
|
||||
case 'three_years':
|
||||
date =
|
||||
today.getMonth() < 10
|
||||
? `${today.getFullYear() - 3}-0${today.getMonth() + 1}-01`
|
||||
: `${today.getFullYear() - 3}-${today.getMonth() + 1}-01`;
|
||||
break;
|
||||
default:
|
||||
date =
|
||||
today.getMonth() < 10
|
||||
? `${today.getFullYear() - 1}-0${today.getMonth() + 1}-01`
|
||||
: `${today.getFullYear() - 1}-${today.getMonth() + 1}-01`;
|
||||
break;
|
||||
}
|
||||
return date;
|
||||
};
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
result: subscriptionUsageChartData,
|
||||
request: fetchSubscriptionUsageChart,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const data = await SubscriptionUsageAPI.readSubscriptionUsageChart(
|
||||
calculateDateRange()
|
||||
);
|
||||
return data.data.results;
|
||||
}, [periodSelection]),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubscriptionUsageChart();
|
||||
}, [fetchSubscriptionUsageChart, periodSelection]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentLoading />
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Flex style={{ justifyContent: 'space-between' }}>
|
||||
<FlexItem>
|
||||
<ChartCardTitle>{t`Subscription Compliance`}</ChartCardTitle>
|
||||
</FlexItem>
|
||||
<FlexItem>
|
||||
<CardText component="small">
|
||||
{t`Last recalculation date:`}{' '}
|
||||
{userProfile.systemConfig.HOST_METRIC_SUMMARY_TASK_LAST_TS.slice(
|
||||
0,
|
||||
10
|
||||
)}
|
||||
</CardText>
|
||||
</FlexItem>
|
||||
</Flex>
|
||||
<GraphCardHeader>
|
||||
<GraphCardActions>
|
||||
<Select
|
||||
variant={SelectVariant.single}
|
||||
placeholderText={t`Select period`}
|
||||
aria-label={t`Select period`}
|
||||
typeAheadAriaLabel={t`Select period`}
|
||||
className="periodSelect"
|
||||
onToggle={setIsPeriodDropdownOpen}
|
||||
onSelect={(event, selection) => {
|
||||
setIsPeriodDropdownOpen(false);
|
||||
setPeriodSelection(selection);
|
||||
}}
|
||||
selections={periodSelection}
|
||||
isOpen={isPeriodDropdownOpen}
|
||||
noResultsFoundText={t`No results found`}
|
||||
ouiaId="subscription-usage-period-select"
|
||||
>
|
||||
<SelectOption key="year" value="year">
|
||||
{t`Past year`}
|
||||
</SelectOption>
|
||||
<SelectOption key="two_years" value="two_years">
|
||||
{t`Past two years`}
|
||||
</SelectOption>
|
||||
<SelectOption key="three_years" value="three_years">
|
||||
{t`Past three years`}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</GraphCardActions>
|
||||
</GraphCardHeader>
|
||||
<CardBody>
|
||||
<UsageChart
|
||||
period={periodSelection}
|
||||
height={600}
|
||||
id="d3-usage-line-chart-root"
|
||||
data={subscriptionUsageChartData}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
export default SubscriptionUsageChart;
|
||||
Loading…
x
Reference in New Issue
Block a user