From 2980c0de4e0b0e300cdafeb7a2bf700e9fa23777 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Tue, 4 Feb 2025 10:32:59 +0100 Subject: [PATCH] refactor(1-3336): useTrafficData / NetworkTrafficUsage.tsx cleanup (#9191) This PR refactors the `NetworkTrafficUsage.tsx` and `useTrafficData` files a bit. The primary objective was to make the network traffic usage component easier to work with, so I suggest to the reviewer that they start there. Part of that refactoring, was taking things out of the useTraffic hook that didn't need to be there. In the end, I'd removed so much that I didn't even need the hook itself in the new component, so I switched that to a regular useState. It made more sense to me to put some of the functions inside the hook into a separate file and import them directly (because they don't rely on any hook state), so I have done that and removed those functions from the trafficData hook. In this case, I also moved the tests. I have not added any new tests in this PR, but will do so in a follow-up. The functions I intend to test have been marked as such. --- .../BillingPlan/BillingDetailsPAYG.tsx | 2 +- .../BillingPlan/BillingDetailsPro.tsx | 2 +- .../NetworkTrafficUsage.tsx | 174 ++++++--------- .../NetworkTrafficUsage/PeriodSelector.tsx | 62 +---- .../NetworkTrafficUsage/RequestSummary.tsx | 2 +- .../chart-data-selection.ts | 29 +++ .../NetworkTrafficUsage/chart-functions.ts | 136 +++++++++++ .../network/NetworkTrafficUsage/dates.ts | 13 ++ .../NetworkTrafficUsage/endpoint-info.ts | 23 ++ .../NetworkTrafficUsage/selectable-periods.ts | 56 +++++ .../useInstanceTrafficMetrics.ts | 43 +--- frontend/src/hooks/useTrafficData.ts | 211 +----------------- .../traffic-calculations.test.ts} | 6 +- frontend/src/utils/traffic-calculations.ts | 157 +++++++++++++ 14 files changed, 504 insertions(+), 412 deletions(-) create mode 100644 frontend/src/component/admin/network/NetworkTrafficUsage/chart-data-selection.ts create mode 100644 frontend/src/component/admin/network/NetworkTrafficUsage/chart-functions.ts create mode 100644 frontend/src/component/admin/network/NetworkTrafficUsage/dates.ts create mode 100644 frontend/src/component/admin/network/NetworkTrafficUsage/endpoint-info.ts create mode 100644 frontend/src/component/admin/network/NetworkTrafficUsage/selectable-periods.ts rename frontend/src/{hooks/useTrafficData.test.ts => utils/traffic-calculations.test.ts} (97%) create mode 100644 frontend/src/utils/traffic-calculations.ts diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPAYG.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPAYG.tsx index 30cce5032f..54afb5024d 100644 --- a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPAYG.tsx +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPAYG.tsx @@ -15,6 +15,7 @@ import { useTrafficDataEstimation } from 'hooks/useTrafficData'; import { useInstanceTrafficMetrics } from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics'; import { useMemo } from 'react'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { calculateOverageCost } from 'utils/traffic-calculations'; const StyledInfoLabel = styled(Typography)(({ theme }) => ({ fontSize: theme.fontSizes.smallBody, @@ -39,7 +40,6 @@ export const BillingDetailsPAYG = ({ toTrafficUsageSum, endpointsInfo, getDayLabels, - calculateOverageCost, } = useTrafficDataEstimation(); const eligibleUsers = users.filter((user) => user.email); diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPro.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPro.tsx index 08c8467690..d4eb3b5e67 100644 --- a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPro.tsx +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPro.tsx @@ -17,6 +17,7 @@ import { BILLING_TRAFFIC_BUNDLE_PRICE, } from './BillingPlan'; import { useInstanceTrafficMetrics } from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics'; +import { calculateOverageCost } from 'utils/traffic-calculations'; const StyledInfoLabel = styled(Typography)(({ theme }) => ({ fontSize: theme.fontSizes.smallBody, @@ -47,7 +48,6 @@ export const BillingDetailsPro = ({ toTrafficUsageSum, endpointsInfo, getDayLabels, - calculateOverageCost, } = useTrafficDataEstimation(); const eligibleUsers = users.filter((user) => user.email); diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/NetworkTrafficUsage.tsx b/frontend/src/component/admin/network/NetworkTrafficUsage/NetworkTrafficUsage.tsx index 0e10cbd4da..d0a3522b48 100644 --- a/frontend/src/component/admin/network/NetworkTrafficUsage/NetworkTrafficUsage.tsx +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/NetworkTrafficUsage.tsx @@ -28,11 +28,7 @@ import type { Theme } from '@mui/material/styles/createTheme'; import Grid from '@mui/material/Grid'; import { NetworkTrafficUsagePlanSummary } from './NetworkTrafficUsagePlanSummary'; import annotationPlugin from 'chartjs-plugin-annotation'; -import { - type ChartDatasetType, - newToChartData, - useTrafficDataEstimation, -} from 'hooks/useTrafficData'; +import { useTrafficDataEstimation } from 'hooks/useTrafficData'; import { customHighlightPlugin } from 'component/common/Chart/customHighlightPlugin'; import { formatTickValue } from 'component/common/Chart/formatTickValue'; import { useTrafficLimit } from './hooks/useTrafficLimit'; @@ -40,10 +36,22 @@ import { BILLING_TRAFFIC_BUNDLE_PRICE } from 'component/admin/billing/BillingDas import { useLocationSettings } from 'hooks/useLocationSettings'; import { PeriodSelector } from './PeriodSelector'; import { useUiFlag } from 'hooks/useUiFlag'; -import { format } from 'date-fns'; -import { monthlyTrafficDataToCurrentUsage } from './monthly-traffic-data-to-current-usage'; import { OverageInfo, RequestSummary } from './RequestSummary'; import { averageTrafficPreviousMonths } from './average-traffic-previous-months'; +import { + calculateTotalUsage, + calculateOverageCost, + calculateEstimatedMonthlyCost, +} from 'utils/traffic-calculations'; +import { currentDate, currentMonth } from './dates'; +import { endpointsInfo } from './endpoint-info'; +import { type ChartDataSelection, toDateRange } from './chart-data-selection'; +import { + type ChartDatasetType, + getChartLabel, + toChartData as newToChartData, +} from './chart-functions'; +import { periodsRecord, selectablePeriods } from './selectable-periods'; const StyledBox = styled(Box)(({ theme }) => ({ display: 'grid', @@ -149,8 +157,7 @@ const createBarChartOptions = ( }, }); -// this is primarily for dev purposes. The existing grid is very inflexible, so we might want to change it, but for demoing the design, this is enough. -const NewHeader = styled('div')(({ theme }) => ({ +const TopRow = styled('div')(({ theme }) => ({ display: 'flex', flexFlow: 'row wrap', justifyContent: 'space-between', @@ -178,15 +185,12 @@ const NewNetworkTrafficUsage: FC = () => { const { isOss } = useUiConfig(); const { locationSettings } = useLocationSettings(); - const { - record, - newPeriod, - setNewPeriod, - toTrafficUsageSum, - calculateOverageCost, - calculateEstimatedMonthlyCost, - endpointsInfo, - } = useTrafficDataEstimation(); + + const [chartDataSelection, setChartDataSelection] = + useState({ + grouping: 'daily', + month: selectablePeriods[0].key, + }); const includedTraffic = useTrafficLimit(); @@ -194,8 +198,8 @@ const NewNetworkTrafficUsage: FC = () => { return createBarChartOptions( theme, (tooltipItems: any) => { - if (newPeriod.grouping === 'daily') { - const periodItem = record[newPeriod.month]; + if (chartDataSelection.grouping === 'daily') { + const periodItem = periodsRecord[chartDataSelection.month]; const tooltipDate = new Date( periodItem.year, periodItem.month, @@ -225,65 +229,42 @@ const NewNetworkTrafficUsage: FC = () => { }, includedTraffic, ); - }, [theme, newPeriod]); + }, [theme, chartDataSelection]); - const traffic = useInstanceTrafficMetrics2(newPeriod); + const traffic = useInstanceTrafficMetrics2( + chartDataSelection.grouping, + toDateRange(chartDataSelection), + ); const data = newToChartData(traffic.usage); + const usageTotal = calculateTotalUsage(traffic.usage); + const overageCost = calculateOverageCost( + usageTotal, + includedTraffic, + BILLING_TRAFFIC_BUNDLE_PRICE, + ); - const [usageTotal, setUsageTotal] = useState(0); + const estimatedMonthlyCost = calculateEstimatedMonthlyCost( + chartDataSelection.grouping === 'daily' + ? chartDataSelection.month + : currentMonth, + data.datasets, + includedTraffic, + currentDate, + BILLING_TRAFFIC_BUNDLE_PRICE, + ); - const [overageCost, setOverageCost] = useState(0); + const showOverageCalculations = + chartDataSelection.grouping === 'daily' && + includedTraffic > 0 && + usageTotal - includedTraffic > 0 && + estimateTrafficDataCost; - const [estimatedMonthlyCost, setEstimatedMonthlyCost] = useState(0); - - useEffect(() => { - if (data) { - let usage: number; - if (newPeriod.grouping === 'monthly') { - usage = monthlyTrafficDataToCurrentUsage(traffic.usage); - } else { - usage = toTrafficUsageSum(data.datasets); - } - - setUsageTotal(usage); - if (includedTraffic > 0) { - const calculatedOverageCost = calculateOverageCost( - usage, - includedTraffic, - BILLING_TRAFFIC_BUNDLE_PRICE, - ); - setOverageCost(calculatedOverageCost); - - setEstimatedMonthlyCost( - calculateEstimatedMonthlyCost( - newPeriod.grouping === 'daily' - ? newPeriod.month - : format(new Date(), 'yyyy-MM'), - data.datasets, - includedTraffic, - new Date(), - BILLING_TRAFFIC_BUNDLE_PRICE, - ), - ); - } - } - }, [data]); - - // todo: extract this (and also endpoints info) - const [lastLabel, ...otherLabels] = Object.values(endpointsInfo) - .map((info) => info.label.toLowerCase()) - .toReversed(); - const requestTypes = `${otherLabels.toReversed().join(', ')}, and ${lastLabel}`; - const chartLabel = - newPeriod.grouping === 'daily' - ? `A bar chart showing daily traffic usage for ${new Date( - newPeriod.month, - ).toLocaleDateString('en-US', { - month: 'long', - year: 'numeric', - })}. Each date shows ${requestTypes} requests.` - : `A bar chart showing monthly total traffic usage for the current month and the preceding ${newPeriod.monthsBack} months. Each month shows ${requestTypes} requests.`; + const showConsumptionBillingWarning = + (chartDataSelection.grouping === 'monthly' || + chartDataSelection.month === currentMonth) && + includedTraffic > 0 && + overageCost > 0; return ( { elseShow={ <> 0 && - overageCost > 0 - } + condition={showConsumptionBillingWarning} show={ Heads up! You are currently @@ -318,12 +293,12 @@ const NewNetworkTrafficUsage: FC = () => { } /> - + { } includedTraffic={includedTraffic} /> - {newPeriod.grouping === 'daily' && - includedTraffic > 0 && - usageTotal - includedTraffic > 0 && - estimateTrafficDataCost && ( - - )} + {showOverageCalculations && ( + + )} - + @@ -390,8 +360,6 @@ const OldNetworkTrafficUsage: FC = () => { toChartData, toTrafficUsageSum, endpointsInfo, - calculateOverageCost, - calculateEstimatedMonthlyCost, } = useTrafficDataEstimation(); const includedTraffic = useTrafficLimit(); diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/PeriodSelector.tsx b/frontend/src/component/admin/network/NetworkTrafficUsage/PeriodSelector.tsx index 3e1c364067..caf2391fb8 100644 --- a/frontend/src/component/admin/network/NetworkTrafficUsage/PeriodSelector.tsx +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/PeriodSelector.tsx @@ -1,70 +1,14 @@ import { styled, Button, Popover, Box, type Theme } from '@mui/material'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp'; -import type { ChartDataSelection } from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics'; import { useRef, useState, type FC } from 'react'; import { format } from 'date-fns'; +import type { ChartDataSelection } from './chart-data-selection'; +import { selectablePeriods } from './selectable-periods'; const dropdownWidth = '15rem'; const dropdownInlinePadding = (theme: Theme) => theme.spacing(3); -export type Period = { - key: string; - dayCount: number; - label: string; - year: number; - month: number; - selectable: boolean; - shortLabel: string; -}; - -export const toSelectablePeriod = ( - date: Date, - label?: string, - selectable = true, -): Period => { - const year = date.getFullYear(); - const month = date.getMonth(); - const period = `${year}-${(month + 1).toString().padStart(2, '0')}`; - const dayCount = new Date(year, month + 1, 0).getDate(); - return { - key: period, - year, - month, - dayCount, - shortLabel: date.toLocaleString('en-US', { - month: 'short', - }), - label: - label || - date.toLocaleString('en-US', { month: 'long', year: 'numeric' }), - selectable, - }; -}; - -const currentDate = new Date(Date.now()); -const currentPeriod = toSelectablePeriod(currentDate, 'Current month'); - -const getSelectablePeriods = (): Period[] => { - const selectablePeriods = [currentPeriod]; - for ( - let subtractMonthCount = 1; - subtractMonthCount < 12; - subtractMonthCount++ - ) { - // JavaScript wraps around the year, so we don't need to handle that. - const date = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - subtractMonthCount, - 1, - ); - selectablePeriods.push( - toSelectablePeriod(date, undefined, date > new Date('2024-03-31')), - ); - } - return selectablePeriods; -}; - const Wrapper = styled('article')(({ theme }) => ({ width: dropdownWidth, paddingBlock: theme.spacing(2), @@ -167,8 +111,6 @@ const StyledPopover = styled(Popover)(({ theme }) => ({ })); export const PeriodSelector: FC = ({ selectedPeriod, setPeriod }) => { - const selectablePeriods = getSelectablePeriods(); - const rangeOptions = [3, 6, 12].map((monthsBack) => ({ value: monthsBack, label: `Last ${monthsBack} months`, diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/RequestSummary.tsx b/frontend/src/component/admin/network/NetworkTrafficUsage/RequestSummary.tsx index da6531dea0..33b35c2869 100644 --- a/frontend/src/component/admin/network/NetworkTrafficUsage/RequestSummary.tsx +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/RequestSummary.tsx @@ -1,9 +1,9 @@ import { Link, styled } from '@mui/material'; import { Badge } from 'component/common/Badge/Badge'; import { subMonths } from 'date-fns'; -import type { ChartDataSelection } from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics'; import { useLocationSettings } from 'hooks/useLocationSettings'; import type { FC } from 'react'; +import type { ChartDataSelection } from './chart-data-selection'; type Props = { period: ChartDataSelection; diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/chart-data-selection.ts b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-data-selection.ts new file mode 100644 index 0000000000..291109c27f --- /dev/null +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-data-selection.ts @@ -0,0 +1,29 @@ +import { endOfMonth, format, startOfMonth, subMonths } from 'date-fns'; + +export type ChartDataSelection = + | { + grouping: 'daily'; + month: string; + } + | { + grouping: 'monthly'; + monthsBack: number; + }; + +// todo: write test +export const toDateRange = ( + selection: ChartDataSelection, +): { from: string; to: string } => { + const fmt = (date: Date) => format(date, 'yyyy-MM-dd'); + if (selection.grouping === 'daily') { + const month = new Date(selection.month); + const from = fmt(month); + const to = fmt(endOfMonth(month)); + return { from, to }; + } else { + const now = new Date(); + const from = fmt(startOfMonth(subMonths(now, selection.monthsBack))); + const to = fmt(endOfMonth(now)); + return { from, to }; + } +}; diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/chart-functions.ts b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-functions.ts new file mode 100644 index 0000000000..d860d12e64 --- /dev/null +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-functions.ts @@ -0,0 +1,136 @@ +import type { ChartDataset } from 'chart.js'; +import type { TrafficUsageDataSegmentedCombinedSchema } from 'openapi'; +import { endpointsInfo } from './endpoint-info'; +import { + addDays, + addMonths, + differenceInCalendarDays, + differenceInCalendarMonths, +} from 'date-fns'; +import { formatDay, formatMonth } from './dates'; +import type { ChartDataSelection } from './chart-data-selection'; +export type ChartDatasetType = ChartDataset<'bar'>; + +// todo: test +export const toChartData = ( + traffic?: TrafficUsageDataSegmentedCombinedSchema, +): { datasets: ChartDatasetType[]; labels: (string | number)[] } => { + if (!traffic) { + return { labels: [], datasets: [] }; + } + + if (traffic.grouping === 'monthly') { + return toMonthlyChartData(traffic); + } else { + return toDailyChartData(traffic); + } +}; + +type SegmentedSchemaApiData = + TrafficUsageDataSegmentedCombinedSchema['apiData'][0]; + +// todo: integrate filtering `filterData` frontend/src/component/admin/network/NetworkTrafficUsage/util.ts +const prepareApiData = ( + apiData: TrafficUsageDataSegmentedCombinedSchema['apiData'], +) => + apiData + .filter((item) => item.apiPath in endpointsInfo) + .sort( + (item1: SegmentedSchemaApiData, item2: SegmentedSchemaApiData) => + endpointsInfo[item1.apiPath].order - + endpointsInfo[item2.apiPath].order, + ); + +const toMonthlyChartData = ( + traffic: TrafficUsageDataSegmentedCombinedSchema, +): { datasets: ChartDatasetType[]; labels: string[] } => { + const from = new Date(traffic.dateRange.from); + const to = new Date(traffic.dateRange.to); + const numMonths = Math.abs(differenceInCalendarMonths(to, from)) + 1; + + const datasets = prepareApiData(traffic.apiData).map( + (item: SegmentedSchemaApiData) => { + const monthsRec: { [month: string]: number } = {}; + for (let i = 0; i < numMonths; i++) { + monthsRec[formatMonth(addMonths(from, i))] = 0; + } + + for (const month of Object.values(item.dataPoints)) { + monthsRec[month.period] = month.trafficTypes[0].count; + } + + const epInfo = endpointsInfo[item.apiPath]; + + return { + label: epInfo.label, + data: Object.values(monthsRec), + backgroundColor: epInfo.color, + hoverBackgroundColor: epInfo.color, + }; + }, + ); + + const labels = Array.from({ length: numMonths }).map((_, index) => + index === numMonths - 1 + ? 'Current month' + : formatMonth(addMonths(from, index)), + ); + + return { datasets, labels }; +}; + +const toDailyChartData = ( + traffic: TrafficUsageDataSegmentedCombinedSchema, +): { datasets: ChartDatasetType[]; labels: number[] } => { + const from = new Date(traffic.dateRange.from); + const to = new Date(traffic.dateRange.to); + const numDays = Math.abs(differenceInCalendarDays(to, from)) + 1; + + const daysRec: { [day: string]: number } = {}; + for (let i = 0; i < numDays; i++) { + daysRec[formatDay(addDays(from, i))] = 0; + } + + const getDaysRec = () => ({ + ...daysRec, + }); + + const datasets = prepareApiData(traffic.apiData).map( + (item: SegmentedSchemaApiData) => { + const daysRec = getDaysRec(); + + for (const day of Object.values(item.dataPoints)) { + daysRec[day.period] = day.trafficTypes[0].count; + } + + const epInfo = endpointsInfo[item.apiPath]; + + return { + label: epInfo.label, + data: Object.values(daysRec), + backgroundColor: epInfo.color, + hoverBackgroundColor: epInfo.color, + }; + }, + ); + + // simplification: assuming days run in a single month from the 1st onwards + const labels = Array.from({ length: numDays }).map((_, index) => index + 1); + + return { datasets, labels }; +}; + +const [lastLabel, ...otherLabels] = Object.values(endpointsInfo) + .map((info) => info.label.toLowerCase()) + .toReversed(); +const requestTypes = `${otherLabels.toReversed().join(', ')}, and ${lastLabel}`; + +export const getChartLabel = (selectedPeriod: ChartDataSelection) => + selectedPeriod.grouping === 'daily' + ? `A bar chart showing daily traffic usage for ${new Date( + selectedPeriod.month, + ).toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + })}. Each date shows ${requestTypes} requests.` + : `A bar chart showing monthly total traffic usage for the current month and the preceding ${selectedPeriod.monthsBack} months. Each month shows ${requestTypes} requests.`; diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/dates.ts b/frontend/src/component/admin/network/NetworkTrafficUsage/dates.ts new file mode 100644 index 0000000000..0976cd2a12 --- /dev/null +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/dates.ts @@ -0,0 +1,13 @@ +import { format, getDaysInMonth } from 'date-fns'; + +export const currentDate = new Date(); + +// making this a constant instead of a function. This avoids re-calculating and +// formatting every time it's called, but it does introduce some edge cases +// where it might stay the same across component renders. +export const currentMonth = format(currentDate, 'yyyy-MM'); + +export const daysInCurrentMonth = getDaysInMonth(currentDate); + +export const formatMonth = (date: Date) => format(date, 'yyyy-MM'); +export const formatDay = (date: Date) => format(date, 'yyyy-MM-dd'); diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/endpoint-info.ts b/frontend/src/component/admin/network/NetworkTrafficUsage/endpoint-info.ts new file mode 100644 index 0000000000..a4849e6a10 --- /dev/null +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/endpoint-info.ts @@ -0,0 +1,23 @@ +export type EndpointInfo = { + label: string; + color: string; + order: number; +}; + +export const endpointsInfo: Record = { + '/api/admin': { + label: 'Admin', + color: '#6D66D9', + order: 1, + }, + '/api/frontend': { + label: 'Frontend', + color: '#A39EFF', + order: 2, + }, + '/api/client': { + label: 'Server', + color: '#D8D6FF', + order: 3, + }, +}; diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/selectable-periods.ts b/frontend/src/component/admin/network/NetworkTrafficUsage/selectable-periods.ts new file mode 100644 index 0000000000..936a4cfef2 --- /dev/null +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/selectable-periods.ts @@ -0,0 +1,56 @@ +import { getDaysInMonth, subMonths } from 'date-fns'; +import { currentDate, formatMonth } from './dates'; + +export type Period = { + key: string; + dayCount: number; + label: string; + year: number; + month: number; + selectable: boolean; + shortLabel: string; +}; + +export const toSelectablePeriod = ( + date: Date, + label?: string, + selectable = true, +): Period => { + const year = date.getFullYear(); + const month = date.getMonth(); + const period = formatMonth(date); + const dayCount = getDaysInMonth(date); + return { + key: period, + year, + month, + dayCount, + shortLabel: date.toLocaleString('en-US', { + month: 'short', + }), + label: + label || + date.toLocaleString('en-US', { month: 'long', year: 'numeric' }), + selectable, + }; +}; + +// todo: test +const generateSelectablePeriodsFromDate = (now: Date) => { + const selectablePeriods = [toSelectablePeriod(now, 'Current month')]; + for ( + let subtractMonthCount = 1; + subtractMonthCount < 12; + subtractMonthCount++ + ) { + const date = subMonths(now, subtractMonthCount); + selectablePeriods.push( + toSelectablePeriod(date, undefined, date >= new Date('2024-05')), + ); + } + return selectablePeriods; +}; +export const selectablePeriods = generateSelectablePeriodsFromDate(currentDate); +export const periodsRecord = Object.fromEntries( + selectablePeriods.map((period) => [period.key, period]), +); diff --git a/frontend/src/hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics.ts b/frontend/src/hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics.ts index dee5ae96c8..c81da21c9e 100644 --- a/frontend/src/hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics.ts +++ b/frontend/src/hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics.ts @@ -4,10 +4,8 @@ import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; import type { TrafficUsageDataSegmentedCombinedSchema, - TrafficUsageDataSegmentedCombinedSchemaApiDataItem, TrafficUsageDataSegmentedSchema, } from 'openapi'; -import { endOfMonth, format, startOfMonth, subMonths } from 'date-fns'; export interface IInstanceTrafficMetricsResponse { usage: TrafficUsageDataSegmentedSchema; @@ -38,34 +36,6 @@ export const useInstanceTrafficMetrics = ( ); }; -export type ChartDataSelection = - | { - grouping: 'daily'; - month: string; - } - | { - grouping: 'monthly'; - monthsBack: number; - }; - -const fromSelection = (selection: ChartDataSelection) => { - const fmt = (date: Date) => format(date, 'yyyy-MM-dd'); - if (selection.grouping === 'daily') { - const month = new Date(selection.month); - const from = fmt(month); - const to = fmt(endOfMonth(month)); - return { from, to }; - } else { - const now = new Date(); - const from = fmt(startOfMonth(subMonths(now, selection.monthsBack))); - const to = fmt(endOfMonth(now)); - return { from, to }; - } -}; - -export type SegmentedSchemaApiData = - TrafficUsageDataSegmentedCombinedSchemaApiDataItem; - export type InstanceTrafficMetricsResponse2 = { usage: TrafficUsageDataSegmentedCombinedSchema; @@ -77,11 +47,16 @@ export type InstanceTrafficMetricsResponse2 = { }; export const useInstanceTrafficMetrics2 = ( - selection: ChartDataSelection, + grouping: 'monthly' | 'daily', + { + from, + to, + }: { + from: string; + to: string; + }, ): InstanceTrafficMetricsResponse2 => { - const { from, to } = fromSelection(selection); - - const apiPath = `api/admin/metrics/traffic-search?grouping=${selection.grouping}&from=${from}&to=${to}`; + const apiPath = `api/admin/metrics/traffic-search?grouping=${grouping}&from=${from}&to=${to}`; const { data, error, mutate } = useSWR(formatApiPath(apiPath), fetcher); diff --git a/frontend/src/hooks/useTrafficData.ts b/frontend/src/hooks/useTrafficData.ts index a70a86de5e..80bace0719 100644 --- a/frontend/src/hooks/useTrafficData.ts +++ b/frontend/src/hooks/useTrafficData.ts @@ -1,21 +1,6 @@ +import type { ChartDatasetType } from 'component/admin/network/NetworkTrafficUsage/chart-functions'; +import type { IInstanceTrafficMetricsResponse } from './api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics'; import { useState } from 'react'; -import type { - ChartDataSelection, - IInstanceTrafficMetricsResponse, - SegmentedSchemaApiData, -} from './api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics'; -import type { ChartDataset } from 'chart.js'; -import { - addDays, - addMonths, - differenceInCalendarDays, - differenceInCalendarMonths, - format, -} from 'date-fns'; -import type { TrafficUsageDataSegmentedCombinedSchema } from 'openapi'; - -const DEFAULT_TRAFFIC_DATA_UNIT_COST = 5; -const DEFAULT_TRAFFIC_DATA_UNIT_SIZE = 1_000_000; export type SelectablePeriod = { key: string; @@ -31,8 +16,6 @@ export type EndpointInfo = { order: number; }; -export type ChartDatasetType = ChartDataset<'bar'>; - const endpointsInfo: Record = { '/api/admin': { label: 'Admin', @@ -51,15 +34,6 @@ const endpointsInfo: Record = { }, }; -const calculateTrafficDataCost = ( - trafficData: number, - trafficUnitCost = DEFAULT_TRAFFIC_DATA_UNIT_COST, - trafficUnitSize = DEFAULT_TRAFFIC_DATA_UNIT_SIZE, -) => { - const unitCount = Math.ceil(trafficData / trafficUnitSize); - return unitCount * trafficUnitCost; -}; - const padMonth = (month: number): string => month.toString().padStart(2, '0'); export const toSelectablePeriod = ( @@ -115,113 +89,6 @@ const toPeriodsRecord = ( {} as Record, ); }; - -export const newToChartData = ( - traffic?: TrafficUsageDataSegmentedCombinedSchema, -): { datasets: ChartDatasetType[]; labels: (string | number)[] } => { - if (!traffic) { - return { labels: [], datasets: [] }; - } - - if (traffic.grouping === 'monthly') { - return toMonthlyChartData(traffic); - } else { - return toDailyChartData(traffic); - } -}; - -const prepareApiData = ( - apiData: TrafficUsageDataSegmentedCombinedSchema['apiData'], -) => - apiData - .filter((item) => item.apiPath in endpointsInfo) - .sort( - (item1: SegmentedSchemaApiData, item2: SegmentedSchemaApiData) => - endpointsInfo[item1.apiPath].order - - endpointsInfo[item2.apiPath].order, - ); - -const toMonthlyChartData = ( - traffic: TrafficUsageDataSegmentedCombinedSchema, -): { datasets: ChartDatasetType[]; labels: string[] } => { - const from = new Date(traffic.dateRange.from); - const to = new Date(traffic.dateRange.to); - const numMonths = Math.abs(differenceInCalendarMonths(to, from)) + 1; - const formatMonth = (date: Date) => format(date, 'yyyy-MM'); - - const datasets = prepareApiData(traffic.apiData).map( - (item: SegmentedSchemaApiData) => { - const monthsRec: { [month: string]: number } = {}; - for (let i = 0; i < numMonths; i++) { - monthsRec[formatMonth(addMonths(from, i))] = 0; - } - - for (const month of Object.values(item.dataPoints)) { - monthsRec[month.period] = month.trafficTypes[0].count; - } - - const epInfo = endpointsInfo[item.apiPath]; - - return { - label: epInfo.label, - data: Object.values(monthsRec), - backgroundColor: epInfo.color, - hoverBackgroundColor: epInfo.color, - }; - }, - ); - - const labels = Array.from({ length: numMonths }).map((_, index) => - index === numMonths - 1 - ? 'Current month' - : formatMonth(addMonths(from, index)), - ); - - return { datasets, labels }; -}; - -const toDailyChartData = ( - traffic: TrafficUsageDataSegmentedCombinedSchema, -): { datasets: ChartDatasetType[]; labels: number[] } => { - const from = new Date(traffic.dateRange.from); - const to = new Date(traffic.dateRange.to); - const numDays = Math.abs(differenceInCalendarDays(to, from)) + 1; - const formatDay = (date: Date) => format(date, 'yyyy-MM-dd'); - - const daysRec: { [day: string]: number } = {}; - for (let i = 0; i < numDays; i++) { - daysRec[formatDay(addDays(from, i))] = 0; - } - - const getDaysRec = () => ({ - ...daysRec, - }); - - const datasets = prepareApiData(traffic.apiData).map( - (item: SegmentedSchemaApiData) => { - const daysRec = getDaysRec(); - - for (const day of Object.values(item.dataPoints)) { - daysRec[day.period] = day.trafficTypes[0].count; - } - - const epInfo = endpointsInfo[item.apiPath]; - - return { - label: epInfo.label, - data: Object.values(daysRec), - backgroundColor: epInfo.color, - hoverBackgroundColor: epInfo.color, - }; - }, - ); - - // simplification: assuming days run in a single month from the 1st onwards - const labels = Array.from({ length: numDays }).map((_, index) => index + 1); - - return { datasets, labels }; -}; - const toChartData = ( days: number[], traffic: IInstanceTrafficMetricsResponse, @@ -285,94 +152,20 @@ const getDayLabels = (dayCount: number): number[] => { return [...Array(dayCount).keys()].map((i) => i + 1); }; -export const calculateOverageCost = ( - dataUsage: number, - includedTraffic: number, - trafficUnitCost = DEFAULT_TRAFFIC_DATA_UNIT_COST, - trafficUnitSize = DEFAULT_TRAFFIC_DATA_UNIT_SIZE, -): number => { - if (dataUsage === 0) { - return 0; - } - - const overage = - Math.floor((dataUsage - includedTraffic) / 1_000_000) * 1_000_000; - return overage > 0 - ? calculateTrafficDataCost(overage, trafficUnitCost, trafficUnitSize) - : 0; -}; - -export const calculateProjectedUsage = ( - today: number, - trafficData: ChartDatasetType[], - daysInPeriod: number, -) => { - if (today < 5) { - return 0; - } - - const spliceToYesterday = today - 1; - const trafficDataUpToYesterday = trafficData.map((item) => { - return { - ...item, - data: item.data.slice(0, spliceToYesterday), - }; - }); - - const dataUsage = toTrafficUsageSum(trafficDataUpToYesterday); - return (dataUsage / spliceToYesterday) * daysInPeriod; -}; - -export const calculateEstimatedMonthlyCost = ( - period: string, - trafficData: ChartDatasetType[], - includedTraffic: number, - currentDate: Date, - trafficUnitCost = DEFAULT_TRAFFIC_DATA_UNIT_COST, - trafficUnitSize = DEFAULT_TRAFFIC_DATA_UNIT_SIZE, -) => { - if (period !== currentPeriod.key) { - return 0; - } - - const today = currentDate.getDate(); - const projectedUsage = calculateProjectedUsage( - today, - trafficData, - currentPeriod.dayCount, - ); - return calculateOverageCost( - projectedUsage, - includedTraffic, - trafficUnitCost, - trafficUnitSize, - ); -}; - export const useTrafficDataEstimation = () => { const selectablePeriods = getSelectablePeriods(); const record = toPeriodsRecord(selectablePeriods); const [period, setPeriod] = useState(selectablePeriods[0].key); - const [newPeriod, setNewPeriod] = useState({ - grouping: 'daily', - month: selectablePeriods[0].key, - }); - return { - calculateTrafficDataCost, record, period, setPeriod, - newPeriod, - setNewPeriod, selectablePeriods, getDayLabels, currentPeriod, toChartData, toTrafficUsageSum, endpointsInfo, - calculateOverageCost, - calculateEstimatedMonthlyCost, }; }; diff --git a/frontend/src/hooks/useTrafficData.test.ts b/frontend/src/utils/traffic-calculations.test.ts similarity index 97% rename from frontend/src/hooks/useTrafficData.test.ts rename to frontend/src/utils/traffic-calculations.test.ts index 1ea416e39e..5879909086 100644 --- a/frontend/src/hooks/useTrafficData.test.ts +++ b/frontend/src/utils/traffic-calculations.test.ts @@ -1,10 +1,10 @@ import { getDaysInMonth } from 'date-fns'; import { - toSelectablePeriod, - calculateOverageCost, calculateEstimatedMonthlyCost, + calculateOverageCost, calculateProjectedUsage, -} from './useTrafficData'; +} from './traffic-calculations'; +import { toSelectablePeriod } from '../component/admin/network/NetworkTrafficUsage/selectable-periods'; const testData4Days = [ { diff --git a/frontend/src/utils/traffic-calculations.ts b/frontend/src/utils/traffic-calculations.ts new file mode 100644 index 0000000000..3a78c0a6c3 --- /dev/null +++ b/frontend/src/utils/traffic-calculations.ts @@ -0,0 +1,157 @@ +import type { + TrafficUsageDataSegmentedCombinedSchema, + TrafficUsageDataSegmentedCombinedSchemaApiDataItem, +} from 'openapi'; +import { + currentMonth, + daysInCurrentMonth, +} from '../component/admin/network/NetworkTrafficUsage/dates'; +import type { ChartDatasetType } from '../component/admin/network/NetworkTrafficUsage/chart-functions'; + +const DEFAULT_TRAFFIC_DATA_UNIT_COST = 5; +const DEFAULT_TRAFFIC_DATA_UNIT_SIZE = 1_000_000; + +// todo: implement and test +export const filterData = ( + data?: TrafficUsageDataSegmentedCombinedSchema, +): TrafficUsageDataSegmentedCombinedSchema | undefined => { + if (!data) { + return; + } + // filter out endpoints not mentioned in endpointsInfo + // filter out any data from before May 2024 + return data; +}; + +const monthlyTrafficDataToCurrentUsage = ( + apiData: TrafficUsageDataSegmentedCombinedSchemaApiDataItem[], +) => { + return apiData.reduce((acc, current) => { + const currentPoint = current.dataPoints.find( + ({ period }) => period === currentMonth, + ); + const pointUsage = + currentPoint?.trafficTypes.reduce( + (acc, next) => acc + next.count, + 0, + ) ?? 0; + return acc + pointUsage; + }, 0); +}; + +const dailyTrafficDataToCurrentUsage = ( + apiData: TrafficUsageDataSegmentedCombinedSchemaApiDataItem[], +) => { + return apiData + .flatMap((endpoint) => + endpoint.dataPoints.flatMap((dataPoint) => + dataPoint.trafficTypes.map((trafficType) => trafficType.count), + ), + ) + .reduce((acc, count) => acc + count, 0); +}; + +// todo: test +// Return the total number of requests for the selected month if showing daily +// data, or the current month if showing monthly data +export const calculateTotalUsage = ( + data?: TrafficUsageDataSegmentedCombinedSchema, +): number => { + if (!data) { + return 0; + } + const { grouping, apiData } = data; + return grouping === 'monthly' + ? monthlyTrafficDataToCurrentUsage(apiData) + : dailyTrafficDataToCurrentUsage(apiData); +}; + +const calculateTrafficDataCost = ( + trafficData: number, + trafficUnitCost = DEFAULT_TRAFFIC_DATA_UNIT_COST, + trafficUnitSize = DEFAULT_TRAFFIC_DATA_UNIT_SIZE, +) => { + const unitCount = Math.ceil(trafficData / trafficUnitSize); + return unitCount * trafficUnitCost; +}; + +export const calculateOverageCost = ( + dataUsage: number, + includedTraffic: number, + trafficUnitCost = DEFAULT_TRAFFIC_DATA_UNIT_COST, + trafficUnitSize = DEFAULT_TRAFFIC_DATA_UNIT_SIZE, +): number => { + if (dataUsage === 0) { + return 0; + } + + const overage = + Math.floor((dataUsage - includedTraffic) / 1_000_000) * 1_000_000; + return overage > 0 + ? calculateTrafficDataCost(overage, trafficUnitCost, trafficUnitSize) + : 0; +}; + +export const calculateProjectedUsage = ( + today: number, + trafficData: ChartDatasetType[], + daysInPeriod: number, +) => { + if (today < 5) { + return 0; + } + + const spliceToYesterday = today - 1; + const trafficDataUpToYesterday = trafficData.map((item) => { + return { + ...item, + data: item.data.slice(0, spliceToYesterday), + }; + }); + + const toTrafficUsageSum = (trafficData: ChartDatasetType[]): number => { + const data = trafficData.reduce( + (acc: number, current: ChartDatasetType) => { + return ( + acc + + current.data.reduce( + (acc_inner, current_inner) => acc_inner + current_inner, + 0, + ) + ); + }, + 0, + ); + return data; + }; + + const dataUsage = toTrafficUsageSum(trafficDataUpToYesterday); + return (dataUsage / spliceToYesterday) * daysInPeriod; +}; + +export const calculateEstimatedMonthlyCost = ( + period: string, + trafficData: ChartDatasetType[], + includedTraffic: number, + currentDate: Date, + trafficUnitCost = DEFAULT_TRAFFIC_DATA_UNIT_COST, + trafficUnitSize = DEFAULT_TRAFFIC_DATA_UNIT_SIZE, +) => { + if (period !== currentMonth) { + return 0; + } + + const today = currentDate.getDate(); + const projectedUsage = calculateProjectedUsage( + today, + trafficData, + daysInCurrentMonth, + ); + + return calculateOverageCost( + projectedUsage, + includedTraffic, + trafficUnitCost, + trafficUnitSize, + ); +};