diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/NetworkTrafficUsage.tsx b/frontend/src/component/admin/network/NetworkTrafficUsage/NetworkTrafficUsage.tsx index d0a3522b48..2264b5e1f5 100644 --- a/frontend/src/component/admin/network/NetworkTrafficUsage/NetworkTrafficUsage.tsx +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/NetworkTrafficUsage.tsx @@ -233,7 +233,7 @@ const NewNetworkTrafficUsage: FC = () => { const traffic = useInstanceTrafficMetrics2( chartDataSelection.grouping, - toDateRange(chartDataSelection), + toDateRange(chartDataSelection, currentDate), ); const data = newToChartData(traffic.usage); diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/chart-data-selection.test.ts b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-data-selection.test.ts new file mode 100644 index 0000000000..9edcb12348 --- /dev/null +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-data-selection.test.ts @@ -0,0 +1,30 @@ +import { type ChartDataSelection, toDateRange } from './chart-data-selection'; + +test('daily conversion', () => { + const input: ChartDataSelection = { + grouping: 'daily', + month: '2021-03', + }; + + const expectedOutput = { + from: '2021-03-01', + to: '2021-03-31', + }; + + expect(toDateRange(input)).toStrictEqual(expectedOutput); +}); + +test('monthly conversion', () => { + const now = new Date('2023-06-15'); + const input: ChartDataSelection = { + grouping: 'monthly', + monthsBack: 3, + }; + + const expectedOutput = { + from: '2023-03-01', + to: '2023-06-30', + }; + + expect(toDateRange(input, now)).toStrictEqual(expectedOutput); +}); diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/chart-data-selection.ts b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-data-selection.ts index 291109c27f..be332ef76d 100644 --- a/frontend/src/component/admin/network/NetworkTrafficUsage/chart-data-selection.ts +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-data-selection.ts @@ -10,9 +10,9 @@ export type ChartDataSelection = monthsBack: number; }; -// todo: write test export const toDateRange = ( selection: ChartDataSelection, + now = new Date(), ): { from: string; to: string } => { const fmt = (date: Date) => format(date, 'yyyy-MM-dd'); if (selection.grouping === 'daily') { @@ -21,7 +21,6 @@ export const toDateRange = ( 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.test.ts b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-functions.test.ts new file mode 100644 index 0000000000..ea426f5a2a --- /dev/null +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-functions.test.ts @@ -0,0 +1,158 @@ +import type { TrafficUsageDataSegmentedCombinedSchema } from 'openapi'; +import { toChartData } from './chart-functions'; +import { endpointsInfo } from './endpoint-info'; + +describe('toChartData', () => { + const dataPoint = (period: string, count: number) => ({ + period, + trafficTypes: [{ count, group: 'successful-requests' }], + }); + + const fromEndpointInfo = (endpoint: keyof typeof endpointsInfo) => { + const info = endpointsInfo[endpoint]; + return { + backgroundColor: info.color, + hoverBackgroundColor: info.color, + label: info.label, + }; + }; + + test('monthly data conversion', () => { + const input: TrafficUsageDataSegmentedCombinedSchema = { + grouping: 'monthly', + dateRange: { + from: '2025-01-01', + to: '2025-06-30', + }, + apiData: [ + { + apiPath: '/api/admin', + dataPoints: [ + dataPoint('2025-06', 5), + dataPoint('2025-05', 4), + dataPoint('2025-02', 6), + dataPoint('2025-04', 2), + ], + }, + { + apiPath: '/api/client', + dataPoints: [ + dataPoint('2025-06', 10), + dataPoint('2025-01', 7), + dataPoint('2025-03', 11), + dataPoint('2025-04', 13), + ], + }, + ], + }; + + const expectedOutput = { + datasets: [ + { + data: [0, 6, 0, 2, 4, 5], + ...fromEndpointInfo('/api/admin'), + }, + { + data: [7, 0, 11, 13, 0, 10], + ...fromEndpointInfo('/api/client'), + }, + ], + labels: [ + '2025-01', + '2025-02', + '2025-03', + '2025-04', + '2025-05', + 'Current month', + ], + }; + + expect(toChartData(input)).toMatchObject(expectedOutput); + }); + + test('daily data conversion', () => { + const input: TrafficUsageDataSegmentedCombinedSchema = { + grouping: 'daily', + dateRange: { + from: '2025-01-01', + to: '2025-01-31', + }, + apiData: [ + { + apiPath: '/api/admin', + dataPoints: [ + dataPoint('2025-01-01', 5), + dataPoint('2025-01-15', 4), + dataPoint('2025-01-14', 6), + dataPoint('2025-01-06', 2), + ], + }, + { + apiPath: '/api/client', + dataPoints: [ + dataPoint('2025-01-02', 2), + dataPoint('2025-01-17', 6), + dataPoint('2025-01-19', 4), + dataPoint('2025-01-06', 8), + ], + }, + ], + }; + + const expectedOutput = { + datasets: [ + { + data: [ + 5, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 6, 4, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ], + ...fromEndpointInfo('/api/admin'), + }, + { + data: [ + 0, 2, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 0, 4, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ], + ...fromEndpointInfo('/api/client'), + }, + ], + labels: Array.from({ length: 31 }).map((_, index) => + (index + 1).toString(), + ), + }; + + expect(toChartData(input)).toMatchObject(expectedOutput); + }); + + test('sorts endpoints according to endpoint data spec', () => { + const input: TrafficUsageDataSegmentedCombinedSchema = { + grouping: 'daily', + dateRange: { + from: '2025-01-01', + to: '2025-01-31', + }, + apiData: [ + { apiPath: '/api/frontend', dataPoints: [] }, + { apiPath: '/api/client', dataPoints: [] }, + { apiPath: '/api/admin', dataPoints: [] }, + ], + }; + + const expectedOutput = { + datasets: [ + { label: 'Admin' }, + { label: 'Frontend' }, + { label: 'Server' }, + ], + }; + + expect(toChartData(input)).toMatchObject(expectedOutput); + }); + + test('returns empty data if traffic is undefined', () => { + expect(toChartData(undefined)).toStrictEqual({ + labels: [], + datasets: [], + }); + }); +}); diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/chart-functions.ts b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-functions.ts index d860d12e64..0e97c2d7cc 100644 --- a/frontend/src/component/admin/network/NetworkTrafficUsage/chart-functions.ts +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/chart-functions.ts @@ -11,113 +11,74 @@ 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)[] } => { +): { datasets: ChartDatasetType[]; labels: string[] } => { 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) + const { newRecord, labels } = getLabelsAndRecords(traffic); + const datasets = traffic.apiData .sort( - (item1: SegmentedSchemaApiData, item2: SegmentedSchemaApiData) => + (item1, item2) => 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; + ) + .map((item) => { + const record = newRecord(); + for (const dataPoint of Object.values(item.dataPoints)) { + record[dataPoint.period] = dataPoint.trafficTypes[0].count; } const epInfo = endpointsInfo[item.apiPath]; return { label: epInfo.label, - data: Object.values(monthsRec), + data: Object.values(record), 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 = ( +const getLabelsAndRecords = ( 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; +) => { + if (traffic.grouping === 'monthly') { + const from = new Date(traffic.dateRange.from); + const to = new Date(traffic.dateRange.to); + const numMonths = Math.abs(differenceInCalendarMonths(to, from)) + 1; + const monthsRec: { [month: string]: number } = {}; + for (let i = 0; i < numMonths; i++) { + monthsRec[formatMonth(addMonths(from, i))] = 0; + } - const daysRec: { [day: string]: number } = {}; - for (let i = 0; i < numDays; i++) { - daysRec[formatDay(addDays(from, i))] = 0; + const labels = Array.from({ length: numMonths }).map((_, index) => + index === numMonths - 1 + ? 'Current month' + : formatMonth(addMonths(from, index)), + ); + return { newRecord: () => ({ ...monthsRec }), labels }; + } else { + 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; + } + + // simplification: the chart only allows for single, full-month views + // when you use a daily chart, so just use the day of the month as the label + const labels = Array.from({ length: numDays }).map((_, index) => + (index + 1).toString(), + ); + + return { newRecord: () => ({ ...daysRec }), labels }; } - - 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) diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/selectable-periods.test.ts b/frontend/src/component/admin/network/NetworkTrafficUsage/selectable-periods.test.ts new file mode 100644 index 0000000000..9bb6c60d20 --- /dev/null +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/selectable-periods.test.ts @@ -0,0 +1,31 @@ +import { generateSelectablePeriodsFromDate } from './selectable-periods'; + +test('marks months before May 2024 as unselectable', () => { + const now = new Date('2025-01-01'); + const selectablePeriods = generateSelectablePeriodsFromDate(now); + + expect( + selectablePeriods.map(({ key, selectable }) => ({ key, selectable })), + ).toEqual([ + { key: '2025-01', selectable: true }, + { key: '2024-12', selectable: true }, + { key: '2024-11', selectable: true }, + { key: '2024-10', selectable: true }, + { key: '2024-09', selectable: true }, + { key: '2024-08', selectable: true }, + { key: '2024-07', selectable: true }, + { key: '2024-06', selectable: true }, + { key: '2024-05', selectable: true }, + { key: '2024-04', selectable: false }, + { key: '2024-03', selectable: false }, + { key: '2024-02', selectable: false }, + ]); +}); + +test('generates 12 months, including the current month', () => { + const now = new Date('2025-01-01'); + const selectablePeriods = generateSelectablePeriodsFromDate(now); + + expect(selectablePeriods.length).toBe(12); + expect(selectablePeriods[0].label).toBe('Current month'); +}); diff --git a/frontend/src/component/admin/network/NetworkTrafficUsage/selectable-periods.ts b/frontend/src/component/admin/network/NetworkTrafficUsage/selectable-periods.ts index a88f71a701..c70b0f5e93 100644 --- a/frontend/src/component/admin/network/NetworkTrafficUsage/selectable-periods.ts +++ b/frontend/src/component/admin/network/NetworkTrafficUsage/selectable-periods.ts @@ -1,4 +1,4 @@ -import { getDaysInMonth, subMonths } from 'date-fns'; +import { getDaysInMonth } from 'date-fns'; import { currentDate, formatMonth } from './dates'; import { TRAFFIC_MEASUREMENT_START_DATE } from 'utils/traffic-calculations'; @@ -36,20 +36,23 @@ export const toSelectablePeriod = ( }; }; -// todo: test -const generateSelectablePeriodsFromDate = (now: Date) => { +export const generateSelectablePeriodsFromDate = (now: Date) => { const selectablePeriods = [toSelectablePeriod(now, 'Current month')]; for ( let subtractMonthCount = 1; subtractMonthCount < 12; subtractMonthCount++ ) { - const date = subMonths(now, subtractMonthCount); + // this complicated calc avoids DST issues + const utcYear = now.getUTCFullYear(); + const utcMonth = now.getUTCMonth(); + const targetMonth = utcMonth - subtractMonthCount; + const targetDate = new Date(Date.UTC(utcYear, targetMonth, 1, 0, 0, 0)); selectablePeriods.push( toSelectablePeriod( - date, + targetDate, undefined, - date >= TRAFFIC_MEASUREMENT_START_DATE, + targetDate >= TRAFFIC_MEASUREMENT_START_DATE, ), ); } diff --git a/frontend/src/utils/traffic-calculations.test.ts b/frontend/src/utils/traffic-calculations.test.ts index dbb6ffe182..ce524d2018 100644 --- a/frontend/src/utils/traffic-calculations.test.ts +++ b/frontend/src/utils/traffic-calculations.test.ts @@ -3,6 +3,7 @@ import { calculateEstimatedMonthlyCost, calculateOverageCost, calculateProjectedUsage, + calculateTotalUsage, cleanTrafficData, } from './traffic-calculations'; import { toSelectablePeriod } from '../component/admin/network/NetworkTrafficUsage/selectable-periods'; @@ -146,7 +147,7 @@ describe('traffic overage calculation', () => { }); describe('filtering out unwanted data', () => { - test('it removes the /edge endpoint data', () => { + it('removes the /edge endpoint data', () => { const input: TrafficUsageDataSegmentedCombinedSchema = { grouping: 'daily', dateRange: { from: '2025-02-01', to: '2025-02-28' }, @@ -171,7 +172,7 @@ describe('filtering out unwanted data', () => { expect(cleanTrafficData(input)).toStrictEqual(expected); }); - test('it removes any data from before the traffic measuring was put in place', () => { + it('removes any data from before the traffic measuring was put in place', () => { const input: TrafficUsageDataSegmentedCombinedSchema = { grouping: 'monthly', dateRange: { @@ -212,3 +213,79 @@ describe('filtering out unwanted data', () => { expect(cleanTrafficData(input)).toStrictEqual(expected); }); }); + +describe('calculateTotalUsage', () => { + const dataPoint = (period: string, count: number) => ({ + period, + trafficTypes: [{ count, group: 'successful-requests' }], + }); + it('calculates total from daily data', () => { + const input: TrafficUsageDataSegmentedCombinedSchema = { + grouping: 'daily', + dateRange: { from: '2025-02-01', to: '2025-02-28' }, + apiData: [ + { + apiPath: '/api/client', + dataPoints: [ + dataPoint('2024-02-01', 1), + dataPoint('2024-02-15', 2), + dataPoint('2024-02-07', 3), + ], + }, + { + apiPath: '/api/admin', + dataPoints: [ + dataPoint('2024-02-01', 4), + dataPoint('2024-02-15', 5), + dataPoint('2024-02-07', 6), + ], + }, + { + apiPath: '/api/frontend', + dataPoints: [ + dataPoint('2024-02-01', 7), + dataPoint('2024-02-15', 8), + dataPoint('2024-02-07', 9), + ], + }, + ], + }; + + expect(calculateTotalUsage(input)).toBe(45); + }); + + it('calculates total for the most recent month in monthly data', () => { + const input: TrafficUsageDataSegmentedCombinedSchema = { + grouping: 'monthly', + dateRange: { from: '2024-10-01', to: '2025-01-31' }, + apiData: [ + { + apiPath: '/api/client', + dataPoints: [ + dataPoint('2025-01', 1), + dataPoint('2024-12', 2), + dataPoint('2024-10', 3), + ], + }, + { + apiPath: '/api/admin', + dataPoints: [ + dataPoint('2025-01', 4), + dataPoint('2024-11', 5), + dataPoint('2024-10', 6), + ], + }, + { + apiPath: '/api/frontend', + dataPoints: [ + dataPoint('2024-11', 7), + dataPoint('2024-12', 8), + dataPoint('2024-10', 9), + ], + }, + ], + }; + + expect(calculateTotalUsage(input)).toBe(5); + }); +}); diff --git a/frontend/src/utils/traffic-calculations.ts b/frontend/src/utils/traffic-calculations.ts index 2ec2bcf128..2513af9b41 100644 --- a/frontend/src/utils/traffic-calculations.ts +++ b/frontend/src/utils/traffic-calculations.ts @@ -7,6 +7,7 @@ import { daysInCurrentMonth, } from '../component/admin/network/NetworkTrafficUsage/dates'; import type { ChartDatasetType } from '../component/admin/network/NetworkTrafficUsage/chart-functions'; +import { format } from 'date-fns'; const DEFAULT_TRAFFIC_DATA_UNIT_COST = 5; const DEFAULT_TRAFFIC_DATA_UNIT_SIZE = 1_000_000; @@ -39,13 +40,13 @@ export const cleanTrafficData = ( return { apiData: cleanedApiData, ...rest }; }; -// todo: extract "currentMonth" into a function argument instead const monthlyTrafficDataToCurrentUsage = ( apiData: TrafficUsageDataSegmentedCombinedSchemaApiDataItem[], + latestMonth: string, ) => { return apiData.reduce((acc, current) => { const currentPoint = current.dataPoints.find( - ({ period }) => period === currentMonth, + ({ period }) => period === latestMonth, ); const pointUsage = currentPoint?.trafficTypes.reduce( @@ -68,9 +69,8 @@ const dailyTrafficDataToCurrentUsage = ( .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 +// data, or the total for the most recent month if showing monthly data export const calculateTotalUsage = ( data?: TrafficUsageDataSegmentedCombinedSchema, ): number => { @@ -78,9 +78,12 @@ export const calculateTotalUsage = ( return 0; } const { grouping, apiData } = data; - return grouping === 'monthly' - ? monthlyTrafficDataToCurrentUsage(apiData) - : dailyTrafficDataToCurrentUsage(apiData); + if (grouping === 'monthly') { + const latestMonth = format(new Date(data.dateRange.to), 'yyyy-MM'); + return monthlyTrafficDataToCurrentUsage(apiData, latestMonth); + } else { + return dailyTrafficDataToCurrentUsage(apiData); + } }; const calculateTrafficDataCost = (