mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: refactor data usage into hooks, estimate monthly added fees (#7048)
- Refactors data processing and overage calculations to separate hooks - Adds support for estimating traffic costs based on monthly usage up to current point - Adds accrued traffic charges to the billing page ![image](https://github.com/Unleash/unleash/assets/707867/39a837c2-5092-49b8-8bbf-46d8757635c0) ![image](https://github.com/Unleash/unleash/assets/707867/55ecfa5e-afe1-4cb6-9aa4-7dd67db4248c)
This commit is contained in:
parent
5c4b835cb5
commit
dfc0c3c63f
@ -1,4 +1,5 @@
|
||||
import type { FC } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Alert, Divider, Grid, styled, Typography } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
@ -15,6 +16,9 @@ import { GridRow } from 'component/common/GridRow/GridRow';
|
||||
import { GridCol } from 'component/common/GridCol/GridCol';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
import { GridColLink } from './GridColLink/GridColLink';
|
||||
import { useInstanceTrafficMetrics } from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics';
|
||||
import { useTrafficDataEstimation } from 'hooks/useTrafficData';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
|
||||
const StyledPlanBox = styled('aside')(({ theme }) => ({
|
||||
padding: theme.spacing(2.5),
|
||||
@ -71,10 +75,21 @@ interface IBillingPlanProps {
|
||||
instanceStatus: IInstanceStatus;
|
||||
}
|
||||
|
||||
const proPlanIncludedRequests = 53_000_000;
|
||||
|
||||
export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
||||
const { users } = useUsers();
|
||||
const expired = trialHasExpired(instanceStatus);
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { uiConfig, isPro } = useUiConfig();
|
||||
|
||||
const {
|
||||
currentPeriod,
|
||||
toChartData,
|
||||
toTrafficUsageSum,
|
||||
endpointsInfo,
|
||||
getDayLabels,
|
||||
calculateOverageCost,
|
||||
} = useTrafficDataEstimation();
|
||||
|
||||
const eligibleUsers = users.filter((user: any) => user.email);
|
||||
|
||||
@ -94,6 +109,32 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
||||
const paidAssignedPrice = price.user * paidAssigned;
|
||||
const finalPrice = planPrice + paidAssignedPrice;
|
||||
const inactive = instanceStatus.state !== InstanceState.ACTIVE;
|
||||
const [totalCost, setTotalCost] = useState(0);
|
||||
|
||||
const flagEnabled = useUiFlag('displayTrafficDataUsage');
|
||||
const [overageCost, setOverageCost] = useState(0);
|
||||
|
||||
const includedTraffic = isPro() ? proPlanIncludedRequests : 0;
|
||||
const traffic = useInstanceTrafficMetrics(currentPeriod.key);
|
||||
|
||||
useEffect(() => {
|
||||
if (flagEnabled && includedTraffic > 0) {
|
||||
const trafficData = toChartData(
|
||||
getDayLabels(currentPeriod.dayCount),
|
||||
traffic,
|
||||
endpointsInfo,
|
||||
);
|
||||
const totalTraffic = toTrafficUsageSum(trafficData);
|
||||
const overageCostCalc = calculateOverageCost(
|
||||
totalTraffic,
|
||||
includedTraffic,
|
||||
);
|
||||
setOverageCost(overageCostCalc);
|
||||
setTotalCost(finalPrice + overageCostCalc);
|
||||
} else {
|
||||
setTotalCost(finalPrice);
|
||||
}
|
||||
}, [traffic]);
|
||||
|
||||
return (
|
||||
<Grid item xs={12} md={7}>
|
||||
@ -185,7 +226,11 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
||||
</Typography>
|
||||
</GridCol>
|
||||
</GridRow>
|
||||
<GridRow>
|
||||
<GridRow
|
||||
sx={(theme) => ({
|
||||
marginBottom: theme.spacing(1.5),
|
||||
})}
|
||||
>
|
||||
<GridCol vertical>
|
||||
<Typography>
|
||||
<strong>Paid members</strong>
|
||||
@ -210,6 +255,40 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
||||
</Typography>
|
||||
</GridCol>
|
||||
</GridRow>
|
||||
<ConditionallyRender
|
||||
condition={flagEnabled && overageCost > 0}
|
||||
show={
|
||||
<GridRow>
|
||||
<GridCol vertical>
|
||||
<Typography>
|
||||
<strong>
|
||||
Accrued traffic charges
|
||||
</strong>
|
||||
<GridColLink>
|
||||
<Link to='/admin/network/data-usage'>
|
||||
view details
|
||||
</Link>
|
||||
</GridColLink>
|
||||
</Typography>
|
||||
<StyledInfoLabel>
|
||||
$5 dollar per 1 million
|
||||
started above included data
|
||||
</StyledInfoLabel>
|
||||
</GridCol>
|
||||
<GridCol>
|
||||
<Typography
|
||||
sx={(theme) => ({
|
||||
fontSize:
|
||||
theme.fontSizes
|
||||
.mainHeader,
|
||||
})}
|
||||
>
|
||||
${overageCost.toFixed(2)}
|
||||
</Typography>
|
||||
</GridCol>
|
||||
</GridRow>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<StyledDivider />
|
||||
<Grid container>
|
||||
@ -223,7 +302,7 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
||||
theme.fontSizes.mainHeader,
|
||||
})}
|
||||
>
|
||||
Total per month
|
||||
Total
|
||||
</Typography>
|
||||
</GridCol>
|
||||
<GridCol>
|
||||
@ -234,7 +313,7 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
||||
fontSize: '2rem',
|
||||
})}
|
||||
>
|
||||
${finalPrice.toFixed(2)}
|
||||
${totalCost.toFixed(2)}
|
||||
</Typography>
|
||||
</GridCol>
|
||||
</GridRow>
|
||||
|
@ -4,7 +4,8 @@ import styled from '@mui/material/styles/styled';
|
||||
import { usePageTitle } from 'hooks/usePageTitle';
|
||||
import Select from 'component/common/select';
|
||||
import Box from '@mui/system/Box';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { Alert, Link } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import {
|
||||
@ -13,7 +14,6 @@ import {
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
type ChartDataset,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
@ -22,136 +22,22 @@ import {
|
||||
} from 'chart.js';
|
||||
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
type IInstanceTrafficMetricsResponse,
|
||||
useInstanceTrafficMetrics,
|
||||
} from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics';
|
||||
import { useInstanceTrafficMetrics } from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics';
|
||||
import type { Theme } from '@mui/material/styles/createTheme';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
import { NetworkTrafficUsagePlanSummary } from './NetworkTrafficUsagePlanSummary';
|
||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
|
||||
type ChartDatasetType = ChartDataset<'bar'>;
|
||||
|
||||
type SelectablePeriod = {
|
||||
key: string;
|
||||
dayCount: number;
|
||||
label: string;
|
||||
year: number;
|
||||
month: number;
|
||||
};
|
||||
|
||||
type EndpointInfo = {
|
||||
label: string;
|
||||
color: string;
|
||||
order: number;
|
||||
};
|
||||
import {
|
||||
type ChartDatasetType,
|
||||
useTrafficDataEstimation,
|
||||
} from 'hooks/useTrafficData';
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
display: 'grid',
|
||||
gap: theme.spacing(5),
|
||||
}));
|
||||
|
||||
const padMonth = (month: number): string =>
|
||||
month < 10 ? `0${month}` : `${month}`;
|
||||
|
||||
const toSelectablePeriod = (date: Date, label?: string): SelectablePeriod => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const period = `${year}-${padMonth(month + 1)}`;
|
||||
const dayCount = new Date(year, month + 1, 0).getDate();
|
||||
return {
|
||||
key: period,
|
||||
year,
|
||||
month,
|
||||
dayCount,
|
||||
label:
|
||||
label ||
|
||||
date.toLocaleString('en-US', { month: 'long', year: 'numeric' }),
|
||||
};
|
||||
};
|
||||
|
||||
const getSelectablePeriods = (): SelectablePeriod[] => {
|
||||
const current = new Date(Date.now());
|
||||
const selectablePeriods = [toSelectablePeriod(current, 'Current month')];
|
||||
for (
|
||||
let subtractMonthCount = 1;
|
||||
subtractMonthCount < 13;
|
||||
subtractMonthCount++
|
||||
) {
|
||||
// JavaScript wraps around the year, so we don't need to handle that.
|
||||
const date = new Date(
|
||||
current.getFullYear(),
|
||||
current.getMonth() - subtractMonthCount,
|
||||
1,
|
||||
);
|
||||
if (date > new Date('2024-03-31')) {
|
||||
selectablePeriods.push(toSelectablePeriod(date));
|
||||
}
|
||||
}
|
||||
return selectablePeriods;
|
||||
};
|
||||
|
||||
const toPeriodsRecord = (
|
||||
periods: SelectablePeriod[],
|
||||
): Record<string, SelectablePeriod> => {
|
||||
return periods.reduce(
|
||||
(acc, period) => {
|
||||
acc[period.key] = period;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, SelectablePeriod>,
|
||||
);
|
||||
};
|
||||
|
||||
const getDayLabels = (dayCount: number): number[] => {
|
||||
return [...Array(dayCount).keys()].map((i) => i + 1);
|
||||
};
|
||||
|
||||
const toChartData = (
|
||||
days: number[],
|
||||
traffic: IInstanceTrafficMetricsResponse,
|
||||
endpointsInfo: Record<string, EndpointInfo>,
|
||||
): ChartDatasetType[] => {
|
||||
if (!traffic || !traffic.usage || !traffic.usage.apiData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = traffic.usage.apiData
|
||||
.filter((item) => !!endpointsInfo[item.apiPath])
|
||||
.sort(
|
||||
(item1: any, item2: any) =>
|
||||
endpointsInfo[item1.apiPath].order -
|
||||
endpointsInfo[item2.apiPath].order,
|
||||
)
|
||||
.map((item: any) => {
|
||||
const daysRec = days.reduce(
|
||||
(acc, day: number) => {
|
||||
acc[`d${day}`] = 0;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
for (const dayKey in item.days) {
|
||||
const day = item.days[dayKey];
|
||||
const dayNum = new Date(Date.parse(day.day)).getDate();
|
||||
daysRec[`d${dayNum}`] = day.trafficTypes[0].count;
|
||||
}
|
||||
const epInfo = endpointsInfo[item.apiPath];
|
||||
|
||||
return {
|
||||
label: epInfo.label,
|
||||
data: Object.values(daysRec),
|
||||
backgroundColor: epInfo.color,
|
||||
hoverBackgroundColor: epInfo.color,
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const customHighlightPlugin = {
|
||||
id: 'customLine',
|
||||
beforeDraw: (chart: Chart) => {
|
||||
@ -295,36 +181,27 @@ const createBarChartOptions = (
|
||||
},
|
||||
});
|
||||
|
||||
const endpointsInfo: Record<string, EndpointInfo> = {
|
||||
'/api/admin': {
|
||||
label: 'Admin',
|
||||
color: '#6D66D9',
|
||||
order: 1,
|
||||
},
|
||||
'/api/frontend': {
|
||||
label: 'Frontend',
|
||||
color: '#A39EFF',
|
||||
order: 2,
|
||||
},
|
||||
'/api/client': {
|
||||
label: 'Server',
|
||||
color: '#D8D6FF',
|
||||
order: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const proPlanIncludedRequests = 53_000_000;
|
||||
|
||||
export const NetworkTrafficUsage: VFC = () => {
|
||||
usePageTitle('Network - Data Usage');
|
||||
const theme = useTheme();
|
||||
|
||||
const selectablePeriods = getSelectablePeriods();
|
||||
const record = toPeriodsRecord(selectablePeriods);
|
||||
const [period, setPeriod] = useState<string>(selectablePeriods[0].key);
|
||||
|
||||
const { isOss, isPro } = useUiConfig();
|
||||
|
||||
const {
|
||||
record,
|
||||
period,
|
||||
setPeriod,
|
||||
selectablePeriods,
|
||||
getDayLabels,
|
||||
toChartData,
|
||||
toTrafficUsageSum,
|
||||
endpointsInfo,
|
||||
calculateOverageCost,
|
||||
calculateEstimatedMonthlyCost,
|
||||
} = useTrafficDataEstimation();
|
||||
|
||||
const includedTraffic = isPro() ? proPlanIncludedRequests : 0;
|
||||
|
||||
const options = useMemo(() => {
|
||||
@ -355,6 +232,10 @@ export const NetworkTrafficUsage: VFC = () => {
|
||||
|
||||
const [usageTotal, setUsageTotal] = useState<number>(0);
|
||||
|
||||
const [overageCost, setOverageCost] = useState<number>(0);
|
||||
|
||||
const [estimatedMonthlyCost, setEstimatedMonthlyCost] = useState<number>(0);
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets,
|
||||
@ -375,20 +256,21 @@ export const NetworkTrafficUsage: VFC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const usage = data.datasets.reduce(
|
||||
(acc: number, current: ChartDatasetType) => {
|
||||
return (
|
||||
acc +
|
||||
current.data.reduce(
|
||||
(acc_inner, current_inner) =>
|
||||
acc_inner + current_inner,
|
||||
0,
|
||||
)
|
||||
);
|
||||
},
|
||||
0,
|
||||
const usage = toTrafficUsageSum(data.datasets);
|
||||
const calculatedOverageCost = calculateOverageCost(
|
||||
usage,
|
||||
includedTraffic,
|
||||
);
|
||||
setUsageTotal(usage);
|
||||
setOverageCost(calculatedOverageCost);
|
||||
setEstimatedMonthlyCost(
|
||||
calculateEstimatedMonthlyCost(
|
||||
period,
|
||||
data.datasets,
|
||||
includedTraffic,
|
||||
new Date(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
@ -398,15 +280,40 @@ export const NetworkTrafficUsage: VFC = () => {
|
||||
show={<Alert severity='warning'>Not enabled.</Alert>}
|
||||
elseShow={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={overageCost > 0}
|
||||
show={
|
||||
<Alert severity='warning' sx={{ mb: 4 }}>
|
||||
<b>Heads up!</b> You are currently consuming
|
||||
more requests than your plan includes and will
|
||||
be billed according to our terms. Please see{' '}
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to='https://www.getunleash.io/pricing'
|
||||
>
|
||||
this page
|
||||
</Link>{' '}
|
||||
for more information. In order to reduce your
|
||||
traffic consumption, you may configure an{' '}
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to='https://docs.getunleash.io/reference/unleash-edge'
|
||||
>
|
||||
Unleash Edge instance
|
||||
</Link>{' '}
|
||||
in your own datacenter.
|
||||
</Alert>
|
||||
}
|
||||
/>
|
||||
<StyledBox>
|
||||
<Grid container component='header' spacing={2}>
|
||||
<Grid item xs={12} md={10}>
|
||||
<Grid item xs={7} md={5.5}>
|
||||
<NetworkTrafficUsagePlanSummary
|
||||
usageTotal={usageTotal}
|
||||
includedTraffic={includedTraffic}
|
||||
/>
|
||||
</Grid>
|
||||
<NetworkTrafficUsagePlanSummary
|
||||
usageTotal={usageTotal}
|
||||
includedTraffic={includedTraffic}
|
||||
overageCost={overageCost}
|
||||
estimatedMonthlyCost={estimatedMonthlyCost}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={2}>
|
||||
<Select
|
||||
|
@ -1,27 +1,38 @@
|
||||
import styled from '@mui/material/styles/styled';
|
||||
import Box from '@mui/system/Box';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import { flexRow } from 'themes/themeStyles';
|
||||
import Link from '@mui/material/Link';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
|
||||
const StyledContainerGrid = styled(Grid)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
}));
|
||||
|
||||
const StyledGrid = styled(Grid)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flex: '1 1',
|
||||
}));
|
||||
|
||||
const StyledColumnGrid = styled(Grid)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: '1 1',
|
||||
}));
|
||||
|
||||
const StyledContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexDirection: 'row',
|
||||
flex: '1 1',
|
||||
padding: theme.spacing(3),
|
||||
border: `2px solid ${theme.palette.divider}`,
|
||||
borderRadius: theme.shape.borderRadiusLarge,
|
||||
}));
|
||||
|
||||
const StyledCardTitleRow = styled(Box)(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}));
|
||||
|
||||
const StyledCardDescription = styled(Box)(({ theme }) => ({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2.5),
|
||||
@ -31,13 +42,13 @@ const StyledCardDescription = styled(Box)(({ theme }) => ({
|
||||
}));
|
||||
|
||||
const RowContainer = styled(Box)(({ theme }) => ({
|
||||
...flexRow,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
}));
|
||||
|
||||
const StyledNumbersDiv = styled('div')(({ theme }) => ({
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
textDecoration: 'none',
|
||||
color: theme.palette.text.primary,
|
||||
}));
|
||||
@ -45,80 +56,112 @@ const StyledNumbersDiv = styled('div')(({ theme }) => ({
|
||||
interface INetworkTrafficUsagePlanSummary {
|
||||
usageTotal: number;
|
||||
includedTraffic: number;
|
||||
overageCost: number;
|
||||
estimatedMonthlyCost: number;
|
||||
}
|
||||
|
||||
export const NetworkTrafficUsagePlanSummary = ({
|
||||
usageTotal,
|
||||
includedTraffic,
|
||||
overageCost,
|
||||
estimatedMonthlyCost,
|
||||
}: INetworkTrafficUsagePlanSummary) => {
|
||||
const overages = usageTotal - includedTraffic;
|
||||
const estimateFlagEnabled = useUiFlag('estimateTrafficDataCost');
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Grid item>
|
||||
<StyledCardTitleRow>
|
||||
<b>Number of requests to Unleash</b>
|
||||
</StyledCardTitleRow>
|
||||
<StyledCardDescription>
|
||||
<RowContainer>
|
||||
Incoming requests selected month{' '}
|
||||
<StyledNumbersDiv>
|
||||
<ConditionallyRender
|
||||
condition={includedTraffic > 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={
|
||||
usageTotal <= includedTraffic
|
||||
<StyledContainerGrid container spacing={4}>
|
||||
<StyledGrid item xs={5.5} md={5.5}>
|
||||
<StyledContainer>
|
||||
<StyledColumnGrid item>
|
||||
<Box>
|
||||
<b>Number of requests to Unleash</b>
|
||||
</Box>
|
||||
<StyledCardDescription>
|
||||
<RowContainer>
|
||||
Incoming requests selected month{' '}
|
||||
<StyledNumbersDiv>
|
||||
<Badge
|
||||
color={
|
||||
includedTraffic > 0
|
||||
? usageTotal <= includedTraffic
|
||||
? 'success'
|
||||
: 'error'
|
||||
: 'neutral'
|
||||
}
|
||||
show={
|
||||
<Badge color='success'>
|
||||
{usageTotal.toLocaleString()}{' '}
|
||||
requests
|
||||
</Badge>
|
||||
}
|
||||
elseShow={
|
||||
<Badge color='error'>
|
||||
{usageTotal.toLocaleString()}{' '}
|
||||
requests
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
}
|
||||
elseShow={
|
||||
<Badge color='neutral'>
|
||||
>
|
||||
{usageTotal.toLocaleString()} requests
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
</StyledNumbersDiv>
|
||||
</RowContainer>
|
||||
</StyledCardDescription>
|
||||
<ConditionallyRender
|
||||
condition={includedTraffic > 0}
|
||||
show={
|
||||
<StyledCardDescription>
|
||||
<RowContainer>
|
||||
Included in your plan monthly
|
||||
<StyledNumbersDiv>
|
||||
{includedTraffic.toLocaleString()} requests
|
||||
</StyledNumbersDiv>
|
||||
</RowContainer>
|
||||
</StyledCardDescription>
|
||||
}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={includedTraffic > 0 && overages > 0}
|
||||
show={
|
||||
<StyledCardDescription>
|
||||
<RowContainer>
|
||||
Requests overages this month
|
||||
<StyledNumbersDiv>
|
||||
{overages.toLocaleString()} requests
|
||||
</StyledNumbersDiv>
|
||||
</RowContainer>
|
||||
</StyledCardDescription>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</StyledContainer>
|
||||
<ConditionallyRender
|
||||
condition={includedTraffic > 0}
|
||||
show={
|
||||
<StyledCardDescription>
|
||||
<RowContainer>
|
||||
Included in your plan monthly
|
||||
<StyledNumbersDiv>
|
||||
{includedTraffic.toLocaleString()}{' '}
|
||||
requests
|
||||
</StyledNumbersDiv>
|
||||
</RowContainer>
|
||||
</StyledCardDescription>
|
||||
}
|
||||
/>
|
||||
</StyledColumnGrid>
|
||||
</StyledContainer>
|
||||
</StyledGrid>
|
||||
<ConditionallyRender
|
||||
condition={
|
||||
estimateFlagEnabled && includedTraffic > 0 && overages > 0
|
||||
}
|
||||
show={
|
||||
<StyledGrid item xs={5.5} md={5.5}>
|
||||
<StyledContainer>
|
||||
<StyledColumnGrid item>
|
||||
<Box>
|
||||
<b>Accrued traffic charges</b>
|
||||
</Box>
|
||||
<StyledCardDescription>
|
||||
<RowContainer>
|
||||
Requests overages this month (
|
||||
<Link href='https://www.getunleash.io/pricing'>
|
||||
pricing
|
||||
</Link>
|
||||
)
|
||||
<StyledNumbersDiv>
|
||||
{overages.toLocaleString()} requests
|
||||
</StyledNumbersDiv>
|
||||
</RowContainer>
|
||||
<RowContainer>
|
||||
Accrued traffic charges
|
||||
<StyledNumbersDiv>
|
||||
<Badge color='secondary'>
|
||||
{overageCost} USD
|
||||
</Badge>
|
||||
</StyledNumbersDiv>
|
||||
</RowContainer>
|
||||
<ConditionallyRender
|
||||
condition={estimatedMonthlyCost > 0}
|
||||
show={
|
||||
<RowContainer>
|
||||
Estimated traffic charges based
|
||||
on current usage
|
||||
<StyledNumbersDiv>
|
||||
<Badge color='secondary'>
|
||||
{estimatedMonthlyCost}{' '}
|
||||
USD
|
||||
</Badge>
|
||||
</StyledNumbersDiv>
|
||||
</RowContainer>
|
||||
}
|
||||
/>
|
||||
</StyledCardDescription>
|
||||
</StyledColumnGrid>
|
||||
</StyledContainer>
|
||||
</StyledGrid>
|
||||
}
|
||||
/>
|
||||
</StyledContainerGrid>
|
||||
);
|
||||
};
|
||||
|
90
frontend/src/hooks/useTrafficData.test.ts
Normal file
90
frontend/src/hooks/useTrafficData.test.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import {
|
||||
toSelectablePeriod,
|
||||
calculateOverageCost,
|
||||
calculateEstimatedMonthlyCost,
|
||||
calculateProjectedUsage,
|
||||
} from './useTrafficData';
|
||||
|
||||
const testData4Days = [
|
||||
{
|
||||
label: 'Frontend',
|
||||
data: [23_000_000, 22_000_000, 24_000_000, 21_000_000],
|
||||
backgroundColor: 'red',
|
||||
hoverBackgroundColor: 'red',
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
data: [23_000_000, 22_000_000, 24_000_000, 21_000_000],
|
||||
backgroundColor: 'red',
|
||||
hoverBackgroundColor: 'red',
|
||||
},
|
||||
{
|
||||
label: 'SDK',
|
||||
data: [23_000_000, 22_000_000, 24_000_000, 21_000_000],
|
||||
backgroundColor: 'red',
|
||||
hoverBackgroundColor: 'red',
|
||||
},
|
||||
];
|
||||
|
||||
describe('traffic overage calculation', () => {
|
||||
it('should return 0 if there is no overage this month', () => {
|
||||
const dataUsage = 52_900_000;
|
||||
const includedTraffic = 53_000_000;
|
||||
const result = calculateOverageCost(dataUsage, includedTraffic);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 5 if overage this month is atleast 1 request above included', () => {
|
||||
const dataUsage = 53_000_001;
|
||||
const includedTraffic = 53_000_000;
|
||||
const result = calculateOverageCost(dataUsage, includedTraffic);
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
it('doesnt estimate when having less than 5 days worth of data', () => {
|
||||
const now = new Date();
|
||||
const period = toSelectablePeriod(now);
|
||||
const testNow = new Date(now.getFullYear(), now.getMonth(), 4);
|
||||
const result = calculateEstimatedMonthlyCost(
|
||||
period.key,
|
||||
testData4Days,
|
||||
53_000_000,
|
||||
testNow,
|
||||
);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('needs 5 days or more to estimate for the month', () => {
|
||||
const testData = testData4Days;
|
||||
testData[0].data.push(23_000_000);
|
||||
testData[1].data.push(23_000_000);
|
||||
testData[2].data.push(23_000_000);
|
||||
const now = new Date();
|
||||
const period = toSelectablePeriod(now);
|
||||
const testNow = new Date(now.getFullYear(), now.getMonth(), 5);
|
||||
const result = calculateEstimatedMonthlyCost(
|
||||
period.key,
|
||||
testData,
|
||||
53_000_000,
|
||||
testNow,
|
||||
);
|
||||
expect(result).toBeGreaterThan(1430);
|
||||
});
|
||||
|
||||
it('estimates projected data usage', () => {
|
||||
const testData = testData4Days;
|
||||
testData[0].data.push(22_500_000);
|
||||
testData[1].data.push(22_500_000);
|
||||
testData[2].data.push(22_500_000);
|
||||
// Testing April 5th of 2024 (30 days)
|
||||
const now = new Date(2024, 3, 5);
|
||||
const period = toSelectablePeriod(now);
|
||||
const result = calculateProjectedUsage(
|
||||
now.getDate(),
|
||||
testData,
|
||||
period.dayCount,
|
||||
);
|
||||
// 22_500_000 * 3 * 30 = 2_025_000_000
|
||||
expect(result).toBe(2_025_000_000);
|
||||
});
|
||||
});
|
238
frontend/src/hooks/useTrafficData.ts
Normal file
238
frontend/src/hooks/useTrafficData.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import { useState } from 'react';
|
||||
import type { IInstanceTrafficMetricsResponse } from './api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics';
|
||||
import type { ChartDataset } from 'chart.js';
|
||||
|
||||
const TRAFFIC_DATA_UNIT_COST = 5;
|
||||
const TRAFFIC_DATA_UNIT_SIZE = 1_000_000;
|
||||
|
||||
export type SelectablePeriod = {
|
||||
key: string;
|
||||
dayCount: number;
|
||||
label: string;
|
||||
year: number;
|
||||
month: number;
|
||||
};
|
||||
|
||||
export type EndpointInfo = {
|
||||
label: string;
|
||||
color: string;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type ChartDatasetType = ChartDataset<'bar'>;
|
||||
|
||||
const endpointsInfo: Record<string, EndpointInfo> = {
|
||||
'/api/admin': {
|
||||
label: 'Admin',
|
||||
color: '#6D66D9',
|
||||
order: 1,
|
||||
},
|
||||
'/api/frontend': {
|
||||
label: 'Frontend',
|
||||
color: '#A39EFF',
|
||||
order: 2,
|
||||
},
|
||||
'/api/client': {
|
||||
label: 'Server',
|
||||
color: '#D8D6FF',
|
||||
order: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const calculateTrafficDataCost = (trafficData: number) => {
|
||||
const unitCount = Math.ceil(trafficData / TRAFFIC_DATA_UNIT_SIZE);
|
||||
return unitCount * TRAFFIC_DATA_UNIT_COST;
|
||||
};
|
||||
|
||||
const padMonth = (month: number): string =>
|
||||
month < 10 ? `0${month}` : `${month}`;
|
||||
|
||||
export const toSelectablePeriod = (
|
||||
date: Date,
|
||||
label?: string,
|
||||
): SelectablePeriod => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const period = `${year}-${padMonth(month + 1)}`;
|
||||
const dayCount = new Date(year, month + 1, 0).getDate();
|
||||
return {
|
||||
key: period,
|
||||
year,
|
||||
month,
|
||||
dayCount,
|
||||
label:
|
||||
label ||
|
||||
date.toLocaleString('en-US', { month: 'long', year: 'numeric' }),
|
||||
};
|
||||
};
|
||||
|
||||
const currentDate = new Date(Date.now());
|
||||
const currentPeriod = toSelectablePeriod(currentDate, 'Current month');
|
||||
|
||||
const getSelectablePeriods = (): SelectablePeriod[] => {
|
||||
const selectablePeriods = [currentPeriod];
|
||||
for (
|
||||
let subtractMonthCount = 1;
|
||||
subtractMonthCount < 13;
|
||||
subtractMonthCount++
|
||||
) {
|
||||
// JavaScript wraps around the year, so we don't need to handle that.
|
||||
const date = new Date(
|
||||
currentDate.getFullYear(),
|
||||
currentDate.getMonth() - subtractMonthCount,
|
||||
1,
|
||||
);
|
||||
if (date > new Date('2024-03-31')) {
|
||||
selectablePeriods.push(toSelectablePeriod(date));
|
||||
}
|
||||
}
|
||||
return selectablePeriods;
|
||||
};
|
||||
|
||||
const toPeriodsRecord = (
|
||||
periods: SelectablePeriod[],
|
||||
): Record<string, SelectablePeriod> => {
|
||||
return periods.reduce(
|
||||
(acc, period) => {
|
||||
acc[period.key] = period;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, SelectablePeriod>,
|
||||
);
|
||||
};
|
||||
|
||||
const toChartData = (
|
||||
days: number[],
|
||||
traffic: IInstanceTrafficMetricsResponse,
|
||||
endpointsInfo: Record<string, EndpointInfo>,
|
||||
): ChartDatasetType[] => {
|
||||
if (!traffic || !traffic.usage || !traffic.usage.apiData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = traffic.usage.apiData
|
||||
.filter((item) => !!endpointsInfo[item.apiPath])
|
||||
.sort(
|
||||
(item1: any, item2: any) =>
|
||||
endpointsInfo[item1.apiPath].order -
|
||||
endpointsInfo[item2.apiPath].order,
|
||||
)
|
||||
.map((item: any) => {
|
||||
const daysRec = days.reduce(
|
||||
(acc, day: number) => {
|
||||
acc[`d${day}`] = 0;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
for (const dayKey in item.days) {
|
||||
const day = item.days[dayKey];
|
||||
const dayNum = new Date(Date.parse(day.day)).getDate();
|
||||
daysRec[`d${dayNum}`] = day.trafficTypes[0].count;
|
||||
}
|
||||
const epInfo = endpointsInfo[item.apiPath];
|
||||
|
||||
return {
|
||||
label: epInfo.label,
|
||||
data: Object.values(daysRec),
|
||||
backgroundColor: epInfo.color,
|
||||
hoverBackgroundColor: epInfo.color,
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
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 getDayLabels = (dayCount: number): number[] => {
|
||||
return [...Array(dayCount).keys()].map((i) => i + 1);
|
||||
};
|
||||
|
||||
export const calculateOverageCost = (
|
||||
dataUsage: number,
|
||||
includedTraffic: number,
|
||||
): number => {
|
||||
if (dataUsage === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const overage = dataUsage - includedTraffic;
|
||||
return overage > 0 ? calculateTrafficDataCost(overage) : 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,
|
||||
) => {
|
||||
if (period !== currentPeriod.key) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const today = currentDate.getDate();
|
||||
const projectedUsage = calculateProjectedUsage(
|
||||
today,
|
||||
trafficData,
|
||||
currentPeriod.dayCount,
|
||||
);
|
||||
return calculateOverageCost(projectedUsage, includedTraffic);
|
||||
};
|
||||
|
||||
export const useTrafficDataEstimation = () => {
|
||||
const selectablePeriods = getSelectablePeriods();
|
||||
const record = toPeriodsRecord(selectablePeriods);
|
||||
const [period, setPeriod] = useState<string>(selectablePeriods[0].key);
|
||||
|
||||
return {
|
||||
calculateTrafficDataCost,
|
||||
record,
|
||||
period,
|
||||
setPeriod,
|
||||
selectablePeriods,
|
||||
getDayLabels,
|
||||
currentPeriod,
|
||||
toChartData,
|
||||
toTrafficUsageSum,
|
||||
endpointsInfo,
|
||||
calculateOverageCost,
|
||||
calculateEstimatedMonthlyCost,
|
||||
};
|
||||
};
|
@ -76,6 +76,7 @@ export type UiFlags = {
|
||||
userAccessUIEnabled?: boolean;
|
||||
outdatedSdksBanner?: boolean;
|
||||
displayTrafficDataUsage?: boolean;
|
||||
estimateTrafficDataCost?: boolean;
|
||||
disableShowContextFieldSelectionValues?: boolean;
|
||||
projectOverviewRefactorFeedback?: boolean;
|
||||
featureLifecycle?: boolean;
|
||||
|
@ -97,6 +97,7 @@ exports[`should create default config 1`] = `
|
||||
"enableLicense": false,
|
||||
"enableLicenseChecker": false,
|
||||
"encryptEmails": false,
|
||||
"estimateTrafficDataCost": false,
|
||||
"executiveDashboard": false,
|
||||
"executiveDashboardUI": false,
|
||||
"extendedUsageMetrics": false,
|
||||
|
@ -41,6 +41,7 @@ export type IFlagKey =
|
||||
| 'killScheduledChangeRequestCache'
|
||||
| 'collectTrafficDataUsage'
|
||||
| 'displayTrafficDataUsage'
|
||||
| 'estimateTrafficDataCost'
|
||||
| 'useMemoizedActiveTokens'
|
||||
| 'queryMissingTokens'
|
||||
| 'checkEdgeValidTokensFromCache'
|
||||
@ -225,6 +226,10 @@ const flags: IFlags = {
|
||||
process.env.UNLEASH_EXPERIMENTAL_DISPLAY_TRAFFIC_DATA_USAGE,
|
||||
false,
|
||||
),
|
||||
estimateTrafficDataCost: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_ESTIMATE_TRAFFIC_DATA_COST,
|
||||
false,
|
||||
),
|
||||
userAccessUIEnabled: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_USER_ACCESS_UI_ENABLED,
|
||||
false,
|
||||
|
Loading…
Reference in New Issue
Block a user