From 3fab608a1c0a3013da2b49ad43781388be5229b0 Mon Sep 17 00:00:00 2001 From: Mikhail Rodichenko Date: Wed, 1 Mar 2023 11:48:40 +0100 Subject: [PATCH] Billing enhancements: storage layers (#3080) - summary chart --- .../reports/charts/get-quotas-datasets.js | 15 +- .../get-datasets-by-storage-class.js | 152 ++++++++ .../billing/reports/charts/summary.js | 252 +++++++++---- .../billing/reports/discounts/apply.js | 133 ++++++- .../billing/reports/storage-report.js | 357 ++---------------- .../billing/reports/storage-table.js | 321 ++++++++++++++++ .../models/billing/base-billing-request.js | 31 +- client/src/models/billing/get-billing-data.js | 6 +- .../models/billing/get-data-with-previous.js | 1 + client/src/models/billing/join-periods.js | 6 +- client/src/models/billing/utils.js | 8 + 11 files changed, 843 insertions(+), 439 deletions(-) create mode 100644 client/src/components/billing/reports/charts/object-storage/get-datasets-by-storage-class.js create mode 100644 client/src/components/billing/reports/storage-table.js diff --git a/client/src/components/billing/reports/charts/get-quotas-datasets.js b/client/src/components/billing/reports/charts/get-quotas-datasets.js index 9b9e475ac4..6de45face8 100644 --- a/client/src/components/billing/reports/charts/get-quotas-datasets.js +++ b/client/src/components/billing/reports/charts/get-quotas-datasets.js @@ -140,7 +140,8 @@ export default function getQuotaDatasets ( compute, storages, quotas, - data = [] + data = [], + maximum = undefined ) { let accessor = () => quotas?.overallGlobal || {}; if (compute && !storages) { @@ -170,14 +171,18 @@ export default function getQuotaDatasets ( affectivePeriods = [Period.quarter, Period.year]; break; } - const { - max: maximum = Infinity - } = getVisibleDataRange(data); + let maximumValue = maximum; + if (maximumValue === undefined) { + const { + max = Infinity + } = getVisibleDataRange(data); + maximumValue = max; + } const quotaValues = affectivePeriods .map(getQuotaPeriodForReportPeriod) .map(quotaPeriod => ({period: quotaPeriod, quota: quotaInfo[quotaPeriod]})) .filter(info => info.quota !== undefined && !Number.isNaN(Number(info.quota))) - .filter(info => info.quota <= maximum); // filter quotas that not in displayed range + .filter(info => info.quota <= maximumValue); // filter quotas that not in displayed range return getRangedQuotaDatasets(quotaValues, data, dataRequest.filters) .map(rangedDataset => ({ data: rangedDataset.dataset, diff --git a/client/src/components/billing/reports/charts/object-storage/get-datasets-by-storage-class.js b/client/src/components/billing/reports/charts/object-storage/get-datasets-by-storage-class.js new file mode 100644 index 0000000000..3c54ef0e41 --- /dev/null +++ b/client/src/components/billing/reports/charts/object-storage/get-datasets-by-storage-class.js @@ -0,0 +1,152 @@ +/* + * Copyright 2017-2022 EPAM Systems, Inc. (https://www.epam.com/) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {costTickFormatter} from '../../utilities'; + +export function getSummaryDatasetsByStorageClass (storageClass) { + const total = /^total$/i.test(storageClass); + const getCostDetailsValue = (costDetails, key) => { + if ( + !costDetails || + !costDetails.tiers || + !costDetails.tiers[storageClass] + ) { + return undefined; + } + return costDetails.tiers[storageClass][key]; + }; + const getCostDetailsSumm = (costDetails, ...keys) => { + const values = keys.map((key) => getCostDetailsValue(costDetails, key)); + if (values.some((value) => value !== undefined && !Number.isNaN(Number(value)))) { + return values.reduce((summ, value) => (summ + (value || 0)), 0); + } + return undefined; + }; + const currentDataset = { + accumulative: { + value: (item) => getCostDetailsSumm( + item.costDetails, + 'accumulativeCost', + 'accumulativeOldVersionCost' + ), + options: { + borderWidth: 3, + tooltipValue: (currentValue, item) => { + if (item) { + const currentValue = getCostDetailsValue( + item.costDetails, + 'accumulativeCost' + ) || 0; + const oldVersionsValue = getCostDetailsValue( + item.costDetails, + 'accumulativeOldVersionCost' + ) || 0; + const current = costTickFormatter(currentValue); + const oldVersion = costTickFormatter(oldVersionsValue); + const total = costTickFormatter(currentValue + oldVersionsValue); + return `${total} (current: ${current}; old versions: ${oldVersion})`; + } + return currentValue; + } + } + }, + fact: { + value: (item) => getCostDetailsSumm( + item.costDetails, + 'cost' + ), + options: { + subTitle: 'cost', + stack: 'current', + tooltipValue: (currentValue, item) => { + if (item) { + const currentValue = getCostDetailsValue( + item.costDetails, + 'cost' + ) || 0; + const oldVersionsValue = getCostDetailsValue( + item.costDetails, + 'oldVersionCost' + ) || 0; + const current = costTickFormatter(currentValue); + const oldVersion = costTickFormatter(oldVersionsValue); + const total = costTickFormatter(currentValue + oldVersionsValue); + return `${total} (current: ${current}; old versions: ${oldVersion})`; + } + return currentValue; + } + } + } + }; + + const currentOldVersionsDataset = { + accumulative: { + value: (item) => getCostDetailsSumm( + item.costDetails, + 'accumulativeOldVersionCost' + ), + options: { + borderWidth: 2, + backgroundColor: 'transparent', + dashed: true, + showTooltip: false + } + }, + fact: { + value: (item) => getCostDetailsSumm( + item.costDetails, + 'oldVersionCost' + ), + options: { + borderWidth: 1, + subTitle: 'cost', + stack: 'current', + backgroundColor: 'transparent', + showTooltip: false + } + } + }; + + const previousDataset = { + accumulative: { + value: (item) => total ? item.previous : getCostDetailsSumm( + item.previousCostDetails, + 'accumulativeCost', + 'accumulativeOldVersionCost' + ), + options: { + isPrevious: true + } + }, + fact: { + value: (item) => total ? item.previousCost : getCostDetailsSumm( + item.previousCostDetails, + 'cost', + 'oldVersionCost' + ), + options: { + isPrevious: true, + subTitle: 'cost', + stack: 'previous' + } + } + }; + return [ + currentDataset, + currentOldVersionsDataset, + previousDataset + ]; +} diff --git a/client/src/components/billing/reports/charts/summary.js b/client/src/components/billing/reports/charts/summary.js index edbe7e1785..2ca5ac6542 100644 --- a/client/src/components/billing/reports/charts/summary.js +++ b/client/src/components/billing/reports/charts/summary.js @@ -148,6 +148,27 @@ function generateLabels (data, filters = {}) { }; } +/** + * @typedef {Object} DatasetOptions + * @property {boolean} [showPoints=true] + * @property {number} currentDateIndex + * @property {number} [borderWidth=2] + * @property {boolean} [dashed=false] + * @property {boolean} [fill=false] + * @property {string} [borderColor] + * @property {string} [backgroundColor=transparent] + * @property {boolean} [isPrevious=false] + * @property {boolean} [showTooltip=true] + */ + +/** + * @param {number[]|{x: number, y: number}[]} data + * @param {string} title + * @param {string} type + * @param {string} color + * @param {DatasetOptions} options + * @returns {*|boolean} + */ function extractDataSet (data, title, type, color, options = {}) { if (dataIsEmpty(data)) { return false; @@ -159,7 +180,10 @@ function extractDataSet (data, title, type, color, options = {}) { fill = false, borderColor = color, backgroundColor = 'transparent', - isPrevious = false + isPrevious = false, + showTooltip = true, + dashed = false, + ...restOptions } = options; const mapItem = (item, index) => { if (typeof item === 'number') { @@ -168,38 +192,102 @@ function extractDataSet (data, title, type, color, options = {}) { return item; }; return { + ...restOptions, [DataLabelPlugin.noDataIgnoreOption]: options[DataLabelPlugin.noDataIgnoreOption], label: title, type, isPrevious, + showTooltip, data: (data || []).map(mapItem), fill, backgroundColor, borderColor, borderWidth, + borderDash: dashed ? [4, 4] : undefined, pointRadius: data.map((e, index) => showPoints && index === currentDateIndex ? 2 : 0), pointBackgroundColor: color, cubicInterpolationMode: 'monotone' }; } -function parse (values) { - const data = (values || []) - .map(d => ({ - date: d.dateValue, - value: d.value || NaN, - cost: d.cost || NaN, - previous: d.previous || NaN, - previousCost: d.previousCost || NaN - })); - return { - currentData: data.map(d => d.cost), - previousData: data.map(d => d.previousCost), - currentAccumulativeData: data.map(d => d.value), - previousAccumulativeData: data.map(d => d.previous) +function extractDatasetData (dataset, data) { + const mapValue = (value) => { + if (value === undefined || value === null || Number.isNaN(Number(value))) { + return Number.NaN; + } + return Number(value); }; + return (data || []) + .map(item => typeof dataset.value === 'function' ? dataset.value(item) : undefined) + .map(mapValue); +} + +function getProcessedDatasetIsPrevious (dataset = {}) { + const { + isPrevious = false + } = dataset.options || {}; + return isPrevious; +} + +function getProcessedDatasetTitle (dataset = {}) { + const { + isPrevious = false, + title = !isPrevious ? 'Current period' : 'Previous period', + subTitle + } = dataset.options || {}; + if (!subTitle) { + return title; + } + return `${title} (${subTitle})`; +} + +function getProcessedDatasetType (dataset = {}) { + const { + isPrevious = false, + datasetType = isPrevious ? SummaryChart.previous : SummaryChart.current + } = dataset.options || {}; + return datasetType; +} + +function getProcessedDatasetColor (dataset = {}, reportThemes = {}) { + const { + isPrevious = false, + color = (isPrevious ? reportThemes.previous : reportThemes.current) + } = dataset.options || {}; + return color; } +const DefaultCurrentDataset = { + accumulative: { + value: (item) => item.value, + options: { + borderWidth: 3 + } + }, + fact: { + value: (item) => item.cost, + options: { + subTitle: 'cost' + } + } +}; + +const DefaultPreviousDataset = { + accumulative: { + value: (item) => item.previous, + options: { + isPrevious: true + } + }, + fact: { + value: (item) => item.previousCost, + options: { + isPrevious: true, + subTitle: 'cost' + } + } +}; + function Summary ( { title, @@ -211,7 +299,8 @@ function Summary ( quotas, quota: showQuota = true, display = Display.accumulative, - reportThemes + reportThemes, + datasets = [DefaultCurrentDataset, DefaultPreviousDataset] } ) { const pending = compute?.pending || storages?.pending; @@ -224,70 +313,58 @@ function Summary ( ); const data = summary ? fillSet(filters, summary.values || []) : []; const {labels, currentDateIndex} = generateLabels(data, filters); - const { - currentData, - previousData, - currentAccumulativeData, - previousAccumulativeData - } = parse(data); + const processedDatasets = datasets + .map((aDataset) => display === Display.accumulative ? aDataset.accumulative : aDataset.fact) + .map((aDataset) => ({ + dataset: aDataset, + current: !aDataset.options || !aDataset.options.isPrevious, + data: extractDatasetData(aDataset, data) + })); + const maximum = Math.max( + ...processedDatasets.map((aDataset) => Math.max( + ...(aDataset.data || []).filter((anItem) => !Number.isNaN(Number(anItem))), + 0 + )), + 0 + ); const shouldDisplayQuotas = display === Display.accumulative && showQuota; const quotaDatasets = shouldDisplayQuotas - ? getQuotaDatasets(compute, storages, quotas, data) + ? getQuotaDatasets(compute, storages, quotas, data, maximum) : []; - const disabled = currentData.length === 0 && previousData.length === 0; + const disabled = !processedDatasets.some(aDataset => aDataset.data.length > 0); const loading = pending && !loaded; + const chartDatasets = [ + ...processedDatasets.map((processed, index) => extractDataSet( + processed.data, + getProcessedDatasetTitle(processed.dataset), + display === Display.fact ? 'bar' : getProcessedDatasetType(processed.dataset), + getProcessedDatasetColor(processed.dataset, reportThemes), + { + currentDateIndex, + borderWidth: 2, + backgroundColor: display === Display.fact + ? getProcessedDatasetColor(processed.dataset, reportThemes) + : 'transparent', + stack: `stack-${index}`, + ...(processed.dataset.options || {}), + isPrevious: getProcessedDatasetIsPrevious(processed.dataset) + } + )), + ...quotaDatasets.map(quotaDataset => extractDataSet( + quotaDataset.data, + quotaDataset.title, + SummaryChart.quota, + reportThemes.quota, + { + showPoints: false, + currentDateIndex, + [DataLabelPlugin.noDataIgnoreOption]: true + } + )) + ].filter(Boolean); const dataConfiguration = { labels: labels.map(l => l.text), - datasets: [ - display === Display.accumulative ? extractDataSet( - currentAccumulativeData, - 'Current period', - SummaryChart.current, - reportThemes.current, - {currentDateIndex, borderWidth: 3} - ) : false, - display === Display.fact ? extractDataSet( - currentData, - 'Current period (cost)', - 'bar', - reportThemes.current, - { - backgroundColor: reportThemes.current, - currentDateIndex, - borderWidth: 1 - } - ) : false, - display === Display.accumulative ? extractDataSet( - previousAccumulativeData, - 'Previous period', - SummaryChart.previous, - reportThemes.previous, - {currentDateIndex} - ) : false, - display === Display.fact ? extractDataSet( - previousData, - 'Previous period (cost)', - 'bar', - reportThemes.previous, - { - backgroundColor: reportThemes.previous, - currentDateIndex, - borderWidth: 1, - isPrevious: true - } - ) : false, - ...quotaDatasets.map(quotaDataset => extractDataSet( - quotaDataset.data, - quotaDataset.title, - SummaryChart.quota, - reportThemes.quota, - { - showPoints: false, - currentDateIndex, - [DataLabelPlugin.noDataIgnoreOption]: true - } - )) - ].filter(Boolean) + datasets: chartDatasets }; const options = { animation: {duration: 0}, @@ -322,7 +399,8 @@ function Summary ( display: !disabled, callback: o => costTickFormatter(o), fontColor: reportThemes.textColor - } + }, + stacked: display === Display.fact }] }, legend: { @@ -339,8 +417,18 @@ function Summary ( title: function () { return undefined; }, - label: function (tooltipItem, data) { - let {label, type, isPrevious, data: items} = data.datasets[tooltipItem.datasetIndex]; + label: function (tooltipItem, chartData) { + const { + label, + type, + isPrevious, + data: items, + showTooltip, + tooltipValue = (o) => o + } = chartData.datasets[tooltipItem.datasetIndex]; + if (!showTooltip) { + return undefined; + } let value = costTickFormatter(tooltipItem.yLabel); if (type === SummaryChart.quota) { const {quota} = (items || [])[tooltipItem.index || 0]; @@ -348,16 +436,18 @@ function Summary ( return `${label || 'Quota'}: ${value}`; } const {xLabel: defaultTitle, index} = tooltipItem; + let displayLabel = label; if (index >= 0 && index < labels.length) { const {tooltip, previousTooltip} = labels[index]; if (type === SummaryChart.previous || isPrevious) { - label = previousTooltip || defaultTitle; + displayLabel = previousTooltip || defaultTitle; } else { - label = tooltip || defaultTitle; + displayLabel = tooltip || defaultTitle; } } - if (label) { - return `${label}: ${value}`; + value = tooltipValue(value, data[index]); + if (displayLabel) { + return `${displayLabel}: ${value}`; } return value; } diff --git a/client/src/components/billing/reports/discounts/apply.js b/client/src/components/billing/reports/discounts/apply.js index c58637584e..187de34722 100644 --- a/client/src/components/billing/reports/discounts/apply.js +++ b/client/src/components/billing/reports/discounts/apply.js @@ -14,31 +14,104 @@ * limitations under the License. */ -function applyDiscounts (obj, discountFn) { +function applyDiscountsForObject ( + obj, + discountFn, + keysToProcess, + discountPeriodsConfiguration +) { if (!obj) { return obj; } - const keysToProcess = ['value', 'cost', 'previous', 'previousCost', 'spendings']; - const discountPeriod = { - value: v => v.initialDate || v.startDate, - cost: v => v.initialDate || v.startDate, - spendings: v => v.initialDate || v.startDate, - previous: v => v.previousInitialDate, - previousCost: v => v.previousInitialDate - }; const result = {...obj}; for (let i = 0; i < keysToProcess.length; i++) { const key = keysToProcess[i]; if (result.hasOwnProperty(key) && !isNotSet(result[key]) && discountFn) { result[key] = discountFn( result[key], - discountPeriod[key] ? discountPeriod[key](obj) : undefined + discountPeriodsConfiguration(key, obj) ); } } return result; } +function applyDiscountsForTierCostDetails (tier, discountFn, periodFn) { + if (!tier) { + return tier; + } + const keysToProcess = [ + 'cost', + 'oldVersionCost', + 'accumulativeCost', + 'accumulativeOldVersionCost' + ]; + function discountPeriodConfiguration (key, item) { + return periodFn(item); + } + return applyDiscountsForObject( + tier, + discountFn, + keysToProcess, + discountPeriodConfiguration + ); +} + +function applyDiscountsForCostDetails (costDetails, discountFn, periodFn) { + if (!costDetails) { + return costDetails; + } + const { + tiers = {}, + ...rest + } = costDetails; + const processedTiers = {}; + Object.entries(tiers).forEach(([tierKey, tier]) => { + processedTiers[tierKey] = applyDiscountsForTierCostDetails(tier, discountFn, periodFn); + }); + return { + ...rest, + tiers: processedTiers + }; +} + +function applyDiscounts (obj, discountFn) { + if (!obj) { + return obj; + } + const keysToProcess = ['value', 'cost', 'previous', 'previousCost', 'spendings']; + function discountPeriodConfiguration (key, item) { + switch (key) { + case 'value': + case 'cost': + case 'spendings': + return item.initialDate || item.startDate; + case 'previous': + case 'previousCost': + return item.previousInitialDate; + default: + return undefined; + } + } + const result = applyDiscountsForObject( + obj, + discountFn, + keysToProcess, + discountPeriodConfiguration + ); + result.costDetails = applyDiscountsForCostDetails( + result.costDetails, + discountFn, + (item) => item.initialDate || item.startDate + ); + result.previousCostDetails = applyDiscountsForCostDetails( + result.previousCostDetails, + discountFn, + (item) => item.previousInitialDate + ); + return result; +} + function applySummaryDiscounts (request, discountFn) { if (!request || !request.loaded) { return undefined; @@ -158,6 +231,44 @@ function applyDiscountsToObjects (objects, discountFn) { function joinSummaryDiscounts (summaries, discounts) { let result; + const addTierCostDetails = (targetTier, addTier, onlyAccumulative = true) => { + if (!isNotSet(targetTier.accumulativeCost)) { + targetTier.accumulativeCost = safelySumm( + targetTier.accumulativeCost, + addTier.accumulativeCost + ); + } + if (!isNotSet(targetTier.accumulativeOldVersionCost)) { + targetTier.accumulativeOldVersionCost = safelySumm( + targetTier.accumulativeOldVersionCost, + addTier.accumulativeOldVersionCost + ); + } + if (!isNotSet(targetTier.cost) && !onlyAccumulative) { + targetTier.cost = safelySumm( + targetTier.cost, + addTier.cost + ); + } + if (!isNotSet(targetTier.oldVersionCost) && !onlyAccumulative) { + targetTier.oldVersionCost = safelySumm( + targetTier.oldVersionCost, + addTier.oldVersionCost + ); + } + }; + const addCostDetails = (target, add, onlyAccumulative = true) => { + const { + tiers: targetTiers = {} + } = target || {}; + const { + tiers: addTiers = {} + } = add || {}; + Object.entries(targetTiers).forEach(([tier, costDetails]) => { + const addTier = addTiers[tier] || {}; + addTierCostDetails(costDetails, addTier, onlyAccumulative); + }); + }; const add = (target, add, onlyAccumulative = true) => { if (!isNotSet(target.value)) { target.value = safelySumm(target.value, add.value); @@ -171,6 +282,8 @@ function joinSummaryDiscounts (summaries, discounts) { if (!isNotSet(target.previousCost) && !onlyAccumulative) { target.previousCost = safelySumm(target.previousCost, add.previousCost); } + addCostDetails(target.costDetails, add.costDetails, onlyAccumulative); + addCostDetails(target.previousCostDetails, add.previousCostDetails, onlyAccumulative); }; for (let i = 0; i < (summaries || []).length; i++) { const summary = summaries[i]; diff --git a/client/src/components/billing/reports/storage-report.js b/client/src/components/billing/reports/storage-report.js index a34d4bf8cc..f12cfc29af 100644 --- a/client/src/components/billing/reports/storage-report.js +++ b/client/src/components/billing/reports/storage-report.js @@ -16,12 +16,7 @@ import React from 'react'; import {inject, observer} from 'mobx-react'; -import { - Pagination -} from 'antd'; -import moment from 'moment-timezone'; import {computed} from 'mobx'; -import classNames from 'classnames'; import { BarChart, BillingTable, @@ -31,7 +26,6 @@ import { import { costTickFormatter, numberFormatter, - DisplayUser, ResizableContainer } from './utilities'; import BillingNavigation, {RUNNER_SEPARATOR, REGION_SEPARATOR} from '../navigation'; @@ -43,7 +37,7 @@ import { import {Period, getPeriod} from '../../special/periods'; import StorageFilter, {StorageFilters} from './filters/storage-filter'; import Export from './export'; -import Discounts, {discounts} from './discounts'; +import Discounts from './discounts'; import { GetBillingData, GetGroupedStorages, @@ -64,9 +58,13 @@ import { getStorageClassNameByAggregate, parseStorageAggregate, StorageAggregate, - DEFAULT_STORAGE_CLASS_ORDER, getStorageClassByAggregate + DEFAULT_STORAGE_CLASS_ORDER, + getStorageClassByAggregate } from '../navigation/aggregate'; -import styles from './storage-report.css'; +import { + getSummaryDatasetsByStorageClass +} from './charts/object-storage/get-datasets-by-storage-class'; +import StorageTable from './storage-table'; const tablePageSize = 10; @@ -149,7 +147,7 @@ function injection (stores, props) { (storagesTable.fetch)(); const summary = new GetBillingData({ filters: { - ...filters, + ...filtersWithoutOrder, filterBy }, loadCostDetails @@ -172,319 +170,6 @@ function injection (stores, props) { }; } -function renderTable ( - { - storages, - discounts: discountsFn, - height, - aggregate, - showDetails - } -) { - if (!storages || !storages.loaded) { - return null; - } - const storageClass = aggregate && aggregate !== StorageAggregate.default - ? getStorageClassByAggregate(aggregate) - : 'TOTAL'; - const storageClassName = aggregate && aggregate !== StorageAggregate.default - ? getStorageClassNameByAggregate(aggregate) - : undefined; - const getLayerValue = (item, key) => { - const { - costDetails = {}, - value, - usage, - usageLast - } = item || {}; - if (!showDetails) { - switch (key) { - case LAYERS_KEYS.size: return (usageLast || 0); - case LAYERS_KEYS.avgSize: return (usage || 0); - case LAYERS_KEYS.cost: return (value || 0); - default: - return 0; - } - } - const { - tiers = {} - } = costDetails; - const tier = tiers[storageClass] || {}; - return tier[key] || 0; - }; - const getLayerValues = (item, ...keys) => - keys.map((key) => getLayerValue(item, key)).reduce((r, c) => r + c, 0); - const getLayerCostValue = (item, ...keys) => { - const value = getLayerValues(item, ...keys); - return value || showDetails ? costTickFormatter(value || 0) : null; - }; - const getLayerSizeValue = (item, ...keys) => { - const value = getLayerValues(item, ...keys); - return value || showDetails ? numberFormatter(value || 0) : null; - }; - const getDetailedCellsTitle = (title, measure) => { - const details = [ - measure, - storageClassName - ].filter(Boolean); - if (details.length > 0) { - return `${title} (${details.join(', ')})`; - } - return title; - }; - const getDetailedCells = ({ - title, - measure, - key = (title || '').toLowerCase(), - currentKey, - oldVersionsKey, - dataExtractor = ((item, ...keys) => 0) - }) => ([ - { - key, - title: getDetailedCellsTitle(title, measure), - headerSpan: showDetails ? 2 : 1, - render: (item) => { - const total = dataExtractor(item, currentKey, oldVersionsKey); - return ( - - {total} - - ); - }, - className: showDetails - ? classNames(styles.cell, styles.rightAlignedCell, styles.noPadding) - : styles.cell, - headerClassName: showDetails - ? classNames(styles.cell, styles.centeredCell) - : styles.cell - }, - showDetails ? ({ - key: `${key}-old-versions`, - title: '\u00A0', - header: false, - render: (item) => { - const oldVersions = dataExtractor(item, oldVersionsKey); - return ( - - {'/ '} - {oldVersions} - - ); - }, - className: classNames(styles.cell, styles.leftAlignedCell, styles.noPadding) - }) : undefined - ].filter(Boolean)); - const columns = [ - { - key: 'storage', - title: 'Storage', - className: styles.storageCell, - render: ({info, name}) => { - return info && info.name ? info.pathMask || info.name : name; - }, - fixed: true - }, - { - key: 'owner', - title: 'Owner', - dataIndex: 'owner', - render: owner => (), - className: styles.cell - }, - { - key: 'billingCenter', - title: 'Billing Center', - dataIndex: 'billingCenter', - className: styles.cell - }, - { - key: 'storageType', - title: 'Type', - dataIndex: 'storageType', - className: styles.cell - }, - ...getDetailedCells({ - title: 'Cost', - dataExtractor: getLayerCostValue, - currentKey: LAYERS_KEYS.cost, - oldVersionsKey: LAYERS_KEYS.oldVersionCost - }), - ...getDetailedCells({ - title: 'Avg. Vol.', - measure: 'GB', - dataExtractor: getLayerSizeValue, - currentKey: LAYERS_KEYS.avgSize, - oldVersionsKey: LAYERS_KEYS.oldVersionAvgSize - }), - ...getDetailedCells({ - title: 'Cur. Vol.', - measure: 'GB', - dataExtractor: getLayerSizeValue, - currentKey: LAYERS_KEYS.size, - oldVersionsKey: LAYERS_KEYS.oldVersionSize - }), - { - key: 'region', - title: 'Region', - dataIndex: 'region', - className: styles.cell - }, - { - key: 'provider', - title: 'Provider', - dataIndex: 'provider', - className: styles.cell - }, - { - key: 'created', - title: 'Created date', - dataIndex: 'created', - render: (value) => value ? moment.utc(value).format('DD MMM YYYY') : value, - className: styles.cell - } - ]; - const dataSource = Object.values( - discounts.applyGroupedDataDiscounts(storages.value || {}, discountsFn) - ); - console.log(dataSource); - const paginationEnabled = storages && storages.loaded - ? storages.totalPages > 1 - : false; - const getRowClassName = (storage = {}) => { - if (`${(storage.groupingInfo || {}).is_deleted}` === 'true') { - return 'cp-warning-row'; - } - return ''; - }; - return ( -
-
- - - - { - columns - .filter((column) => column.header === undefined || column.header) - .map((column, index) => ( - - )) - } - - - - { - dataSource.map((item, index) => ( - - { - columns.map((column) => ( - - )) - } - - )) - } - -
- {column.title} -
- { - column.render - ? column.render(column.dataIndex ? item[column.dataIndex] : item, item) - : item[column.dataIndex] - } -
- { - /* - { - return info && info.id ? `storage_${info.id}` : `storage_${name}`; - }} - loading={storages.pending} - dataSource={dataSource} - columns={columns} - pagination={false} - size="small" - scroll={{ - x: width, - y: height - (paginationEnabled ? 30 : 0) - 50 - }} - /> - */ - } - - { - paginationEnabled && ( -
- storages.fetchPage(page - 1)} - size="small" - /> -
- ) - } - - ); -} - -const RenderTable = observer(renderTable); - class StorageReports extends React.Component { state = { dataSampleKey: StorageFilters.value.key @@ -551,7 +236,7 @@ class StorageReports extends React.Component { }; @computed - get layersMock () { + get tiersData () { const { filters = {}, tiersRequest @@ -609,6 +294,25 @@ class StorageReports extends React.Component { }; } + @computed + get summaryDatasets () { + const { + type, + filters = {} + } = this.props; + const { + storageAggregate + } = filters; + if (!/^object$/i.test(type)) { + return undefined; + } + const total = !storageAggregate || storageAggregate === StorageAggregate.default; + const storageClass = total + ? 'TOTAL' + : getStorageClassByAggregate(storageAggregate); + return getSummaryDatasetsByStorageClass(storageClass); + } + render () { const { storages, @@ -631,7 +335,7 @@ class StorageReports extends React.Component { } = filters; const costsUsageSelectorHeight = 30; const tiersPending = tiersRequest && tiersRequest.pending; - const tiersData = this.layersMock; + const tiersData = this.tiersData; const valueFormatter = metrics === StorageMetrics.volume ? numberFormatter : costTickFormatter; @@ -681,6 +385,7 @@ class StorageReports extends React.Component { { ({width, height}) => ( { ({height}) => ( - { + const { + costDetails = {}, + value, + usage, + usageLast + } = item || {}; + if (!showDetails) { + switch (key) { + case 'size': return (usageLast || 0); + case 'avgSize': return (usage || 0); + case 'cost': return (value || 0); + default: + return 0; + } + } + const { + tiers = {} + } = costDetails; + const tier = tiers[storageClass] || {}; + return tier[key] || 0; + }; + const getLayerValues = (item, ...keys) => + keys.map((key) => getLayerValue(item, key)).reduce((r, c) => r + c, 0); + const getLayerCostValue = (item, ...keys) => { + const value = getLayerValues(item, ...keys); + return value || showDetails ? costTickFormatter(value || 0) : null; + }; + const getLayerSizeValue = (item, ...keys) => { + const value = getLayerValues(item, ...keys); + return value || showDetails ? numberFormatter(value || 0) : null; + }; + const getDetailedCellsTitle = (title, measure) => { + const details = [ + measure, + storageClassName + ].filter(Boolean); + if (details.length > 0) { + return `${title} (${details.join(', ')})`; + } + return title; + }; + const getDetailedCells = ({ + title, + measure, + key = (title || '').toLowerCase(), + currentKey, + oldVersionsKey, + dataExtractor = ((item, ...keys) => 0) + }) => ([ + { + key, + title: getDetailedCellsTitle(title, measure), + headerSpan: showDetails ? 2 : 1, + render: (item) => { + const total = dataExtractor(item, currentKey, oldVersionsKey); + return ( + + {total} + + ); + }, + className: showDetails + ? classNames(styles.cell, styles.rightAlignedCell, styles.noPadding) + : styles.cell, + headerClassName: showDetails + ? classNames(styles.cell, styles.centeredCell) + : styles.cell + }, + showDetails ? ({ + key: `${key}-old-versions`, + title: '\u00A0', + header: false, + render: (item) => { + const oldVersions = dataExtractor(item, oldVersionsKey); + return ( + + {'/ '} + {oldVersions} + + ); + }, + className: classNames(styles.cell, styles.leftAlignedCell, styles.noPadding) + }) : undefined + ].filter(Boolean)); + const columns = [ + { + key: 'storage', + title: 'Storage', + className: styles.storageCell, + render: ({info, name}) => { + return info && info.name ? info.pathMask || info.name : name; + }, + fixed: true + }, + { + key: 'owner', + title: 'Owner', + dataIndex: 'owner', + render: owner => (), + className: styles.cell + }, + { + key: 'billingCenter', + title: 'Billing Center', + dataIndex: 'billingCenter', + className: styles.cell + }, + { + key: 'storageType', + title: 'Type', + dataIndex: 'storageType', + className: styles.cell + }, + ...getDetailedCells({ + title: 'Cost', + dataExtractor: getLayerCostValue, + currentKey: 'cost', + oldVersionsKey: 'oldVersionCost' + }), + ...getDetailedCells({ + title: 'Avg. Vol.', + measure: 'GB', + dataExtractor: getLayerSizeValue, + currentKey: 'avgSize', + oldVersionsKey: 'oldVersionAvgSize' + }), + ...getDetailedCells({ + title: 'Cur. Vol.', + measure: 'GB', + dataExtractor: getLayerSizeValue, + currentKey: 'size', + oldVersionsKey: 'oldVersionSize' + }), + { + key: 'region', + title: 'Region', + dataIndex: 'region', + className: styles.cell + }, + { + key: 'provider', + title: 'Provider', + dataIndex: 'provider', + className: styles.cell + }, + { + key: 'created', + title: 'Created date', + dataIndex: 'created', + render: (value) => value ? moment.utc(value).format('DD MMM YYYY') : value, + className: styles.cell + } + ]; + const dataSource = Object.values( + discounts.applyGroupedDataDiscounts(storages.value || {}, discountsFn) + ); + const paginationEnabled = storages && storages.loaded + ? storages.totalPages > 1 + : false; + const getRowClassName = (storage = {}) => { + if (`${(storage.groupingInfo || {}).is_deleted}` === 'true') { + return 'cp-warning-row'; + } + return ''; + }; + return ( +
+
+
+ + + { + columns + .filter((column) => column.header === undefined || column.header) + .map((column, index) => ( + + )) + } + + + + { + dataSource.map((item, index) => ( + + { + columns.map((column) => ( + + )) + } + + )) + } + +
+ {column.title} +
+ { + column.render + ? column.render(column.dataIndex ? item[column.dataIndex] : item, item) + : item[column.dataIndex] + } +
+
+ { + paginationEnabled && ( +
+ storages.fetchPage(page - 1)} + size="small" + /> +
+ ) + } +
+ ); +} + +export default observer(StorageTable); diff --git a/client/src/models/billing/base-billing-request.js b/client/src/models/billing/base-billing-request.js index ed8fcc4062..96d7d2eda6 100644 --- a/client/src/models/billing/base-billing-request.js +++ b/client/src/models/billing/base-billing-request.js @@ -188,25 +188,28 @@ export default class BaseBillingRequest extends RemotePost { ...costDetailsRest } = costDetails; const processedTiers = tiers.map(processTier); - const total = { - storageClass: 'TOTAL' - }; - tiers.forEach((aTier) => { - const processKeys = (keys) => { - keys.forEach((aKey) => { - const value = aTier[aKey] || 0; - if (!Number.isNaN(Number(value))) { - total[aKey] = (total[aKey] || 0) + value; - } - }); + if (processedTiers.length > 0) { + const total = { + storageClass: 'TOTAL' }; - processKeys([...costKeys, ...sizeKeys]); - }); + tiers.forEach((aTier) => { + const processKeys = (keys) => { + keys.forEach((aKey) => { + const value = aTier[aKey] || 0; + if (!Number.isNaN(Number(value))) { + total[aKey] = (total[aKey] || 0) + value; + } + }); + }; + processKeys([...costKeys, ...sizeKeys]); + }); + processedTiers.push(processTier(total)); + } return { ...rest, costDetails: { ...costDetailsRest, - tiers: processedTiers.concat(processTier(total)) + tiers: processedTiers .reduce((result, tier) => ({ ...result, [tier.storageClass]: tier diff --git a/client/src/models/billing/get-billing-data.js b/client/src/models/billing/get-billing-data.js index 97d3ae470c..2a8cf1e5f9 100644 --- a/client/src/models/billing/get-billing-data.js +++ b/client/src/models/billing/get-billing-data.js @@ -92,6 +92,7 @@ class GetBillingData extends BaseBillingRequest { if (this.dateFilter(initialDate)) { const momentDate = this.dateMapper(initialDate); res.values.push({ + costDetails: item.costDetails, date: moment(momentDate).format('DD MMM YYYY'), value: isNaN(item.accumulatedCost) ? undefined : costMapper(item.accumulatedCost), cost: isNaN(item.cost) ? undefined : costMapper(item.cost), @@ -132,12 +133,14 @@ class GetBillingDataWithPreviousRange extends GetDataWithPrevious { ...o, previous: o.value, previousCost: o.cost, - previousInitialDate: o.initialDate + previousInitialDate: o.initialDate, + previousCostDetails: o.costDetails })) : []; result.forEach((o) => { delete o.value; delete o.cost; + delete o.costDetails; }); for (let i = 0; i < (currentValues || []).length; i++) { const item = currentValues[i]; @@ -146,6 +149,7 @@ class GetBillingDataWithPreviousRange extends GetDataWithPrevious { if (prev) { prev.value = item.value; prev.cost = item.cost; + prev.costDetails = item.costDetails; } else { result.push(item); } diff --git a/client/src/models/billing/get-data-with-previous.js b/client/src/models/billing/get-data-with-previous.js index 8c11d814f4..9a4bcba250 100644 --- a/client/src/models/billing/get-data-with-previous.js +++ b/client/src/models/billing/get-data-with-previous.js @@ -14,6 +14,7 @@ * limitations under the License. */ +import {action} from 'mobx'; import RemotePost from '../basic/RemotePost'; import defer from '../../utils/defer'; diff --git a/client/src/models/billing/join-periods.js b/client/src/models/billing/join-periods.js index 3b1f3a1326..73caba13f9 100644 --- a/client/src/models/billing/join-periods.js +++ b/client/src/models/billing/join-periods.js @@ -17,7 +17,8 @@ const KeyMappers = { value: 'previous', usage: 'previousUsage', - runsCount: 'previousRunsCount' + runsCount: 'previousRunsCount', + costDetails: 'previousCostDetails' }; export default function join (current = {}, previous = {}, keyMappers = KeyMappers) { @@ -43,7 +44,8 @@ export default function join (current = {}, previous = {}, keyMappers = KeyMappe ...p, value: 0, ...c, - ...prevObj + ...prevObj, + costDetails: c.costDetails }; } return result; diff --git a/client/src/models/billing/utils.js b/client/src/models/billing/utils.js index fe1a04d762..a01aad509e 100644 --- a/client/src/models/billing/utils.js +++ b/client/src/models/billing/utils.js @@ -21,6 +21,14 @@ export function costMapper (value) { return Math.round(+value / 100.0) / 100.0; } +export function minimumCostMapper (value) { + if (!value || isNaN(value)) { + return 0; + } + const minimumValue = (+value > 0) ? 0.01 : 0; + return Math.max(Math.round(+value / 100.0) / 100.0, minimumValue); +} + export function minutesToHours (minutes) { if (!minutes || isNaN(minutes)) { return 0;