From dff30b48bcd4a8e25267f52eca17f41a54eb83a2 Mon Sep 17 00:00:00 2001 From: Salma Kochay Date: Thu, 31 Aug 2023 16:37:52 -0400 Subject: [PATCH] add subscription usage page --- awx/ui/src/api/index.js | 3 + awx/ui/src/api/models/SubscriptionUsage.js | 16 + awx/ui/src/routeConfig.js | 8 + awx/ui/src/routeConfig.test.js | 2 + .../ChartComponents/UsageChart.js | 319 ++++++++++++++++++ .../ChartComponents/UsageChartTooltip.js | 177 ++++++++++ .../SubscriptionUsage/SubscriptionUsage.js | 53 +++ .../SubscriptionUsageChart.js | 167 +++++++++ 8 files changed, 745 insertions(+) create mode 100644 awx/ui/src/api/models/SubscriptionUsage.js create mode 100644 awx/ui/src/screens/SubscriptionUsage/ChartComponents/UsageChart.js create mode 100644 awx/ui/src/screens/SubscriptionUsage/ChartComponents/UsageChartTooltip.js create mode 100644 awx/ui/src/screens/SubscriptionUsage/SubscriptionUsage.js create mode 100644 awx/ui/src/screens/SubscriptionUsage/SubscriptionUsageChart.js diff --git a/awx/ui/src/api/index.js b/awx/ui/src/api/index.js index 5876efc6f1ca..93631c2137fd 100644 --- a/awx/ui/src/api/index.js +++ b/awx/ui/src/api/index.js @@ -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, diff --git a/awx/ui/src/api/models/SubscriptionUsage.js b/awx/ui/src/api/models/SubscriptionUsage.js new file mode 100644 index 000000000000..d5831e3a6c0d --- /dev/null +++ b/awx/ui/src/api/models/SubscriptionUsage.js @@ -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; diff --git a/awx/ui/src/routeConfig.js b/awx/ui/src/routeConfig.js index 1c9b1a498df6..f96fec5c8468 100644 --- a/awx/ui/src/routeConfig.js +++ b/awx/ui/src/routeConfig.js @@ -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: Subscription Usage, + 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'); diff --git a/awx/ui/src/routeConfig.test.js b/awx/ui/src/routeConfig.test.js index 7db858f08cd8..ffbd2587ee81 100644 --- a/awx/ui/src/routeConfig.test.js +++ b/awx/ui/src/routeConfig.test.js @@ -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', diff --git a/awx/ui/src/screens/SubscriptionUsage/ChartComponents/UsageChart.js b/awx/ui/src/screens/SubscriptionUsage/ChartComponents/UsageChart.js new file mode 100644 index 000000000000..38fdb51d6d94 --- /dev/null +++ b/awx/ui/src/screens/SubscriptionUsage/ChartComponents/UsageChart.js @@ -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
; +} + +UsageChart.propTypes = { + id: string.isRequired, + data: arrayOf(shape({})).isRequired, + height: number.isRequired, +}; + +const withPageContext = (Component) => + function contextComponent(props) { + return ( + + {(pageContext) => } + + ); + }; + +export default withPageContext(UsageChart); diff --git a/awx/ui/src/screens/SubscriptionUsage/ChartComponents/UsageChartTooltip.js b/awx/ui/src/screens/SubscriptionUsage/ChartComponents/UsageChartTooltip.js new file mode 100644 index 000000000000..7e8439c6a0d2 --- /dev/null +++ b/awx/ui/src/screens/SubscriptionUsage/ChartComponents/UsageChartTooltip.js @@ -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; diff --git a/awx/ui/src/screens/SubscriptionUsage/SubscriptionUsage.js b/awx/ui/src/screens/SubscriptionUsage/SubscriptionUsage.js new file mode 100644 index 000000000000..a453424debe7 --- /dev/null +++ b/awx/ui/src/screens/SubscriptionUsage/SubscriptionUsage.js @@ -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 && ( + + +

+ A tech preview of the new {brandName} user + interface can be found here. +

+
+
+ )} + + +
+ + + +
+
+ + ); +} + +export default SubscriptionUsage; diff --git a/awx/ui/src/screens/SubscriptionUsage/SubscriptionUsageChart.js b/awx/ui/src/screens/SubscriptionUsage/SubscriptionUsageChart.js new file mode 100644 index 000000000000..134447db7da3 --- /dev/null +++ b/awx/ui/src/screens/SubscriptionUsage/SubscriptionUsageChart.js @@ -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 ( + + + + + + ); + } + + return ( + + + + {t`Subscription Compliance`} + + + + {t`Last recalculation date:`}{' '} + {userProfile.systemConfig.HOST_METRIC_SUMMARY_TASK_LAST_TS.slice( + 0, + 10 + )} + + + + + + + + + + + + + ); +} +export default SubscriptionUsageChart;