1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-28 00:17:12 +01:00

feat(1-3267): use new API for chart creation (#9149)

Adds support for the new /traffic-search API behind a flag. When active, you'll be able to select month ranges as well as specific single months.

Largely copies the existing network traffic component, and adds some minor tweaks to make it work with the new data.

This is quite rough, but it gives us a base to build on for later. There's still things that we need to solve for in following PRs.
This commit is contained in:
Thomas Heartman 2025-01-29 10:43:41 +01:00 committed by GitHub
parent 05e608ab09
commit 87a84426ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 413 additions and 66 deletions

View File

@ -20,13 +20,17 @@ import {
} from 'chart.js'; } from 'chart.js';
import { Bar } from 'react-chartjs-2'; import { Bar } from 'react-chartjs-2';
import { useInstanceTrafficMetrics } from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics'; import {
useInstanceTrafficMetrics,
useInstanceTrafficMetrics2,
} from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics';
import type { Theme } from '@mui/material/styles/createTheme'; import type { Theme } from '@mui/material/styles/createTheme';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import { NetworkTrafficUsagePlanSummary } from './NetworkTrafficUsagePlanSummary'; import { NetworkTrafficUsagePlanSummary } from './NetworkTrafficUsagePlanSummary';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
import { import {
type ChartDatasetType, type ChartDatasetType,
newToChartData,
useTrafficDataEstimation, useTrafficDataEstimation,
} from 'hooks/useTrafficData'; } from 'hooks/useTrafficData';
import { customHighlightPlugin } from 'component/common/Chart/customHighlightPlugin'; import { customHighlightPlugin } from 'component/common/Chart/customHighlightPlugin';
@ -36,6 +40,8 @@ import { BILLING_TRAFFIC_BUNDLE_PRICE } from 'component/admin/billing/BillingDas
import { useLocationSettings } from 'hooks/useLocationSettings'; import { useLocationSettings } from 'hooks/useLocationSettings';
import { PeriodSelector } from './PeriodSelector'; import { PeriodSelector } from './PeriodSelector';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
import { format } from 'date-fns';
import { monthlyTrafficDataToCurrentUsage } from './monthly-traffic-data-to-current-usage';
const StyledBox = styled(Box)(({ theme }) => ({ const StyledBox = styled(Box)(({ theme }) => ({
display: 'grid', display: 'grid',
@ -148,10 +154,173 @@ const NewHeader = styled('div')(() => ({
alignItems: 'flex-start', alignItems: 'flex-start',
})); }));
export const NetworkTrafficUsage: FC = () => { const NewNetworkTrafficUsage: FC = () => {
usePageTitle('Network - Data Usage');
const theme = useTheme();
const { isOss } = useUiConfig();
const { locationSettings } = useLocationSettings();
const {
record,
newPeriod,
setNewPeriod,
toTrafficUsageSum,
calculateOverageCost,
calculateEstimatedMonthlyCost,
} = useTrafficDataEstimation();
const includedTraffic = useTrafficLimit();
const options = useMemo(() => {
return createBarChartOptions(
theme,
(tooltipItems: any) => {
if (newPeriod.grouping === 'daily') {
const periodItem = record[newPeriod.month];
const tooltipDate = new Date(
periodItem.year,
periodItem.month,
Number.parseInt(tooltipItems[0].label),
);
return tooltipDate.toLocaleDateString(
locationSettings?.locale ?? 'en-US',
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
);
} else {
return new Date(tooltipItems[0].label).toLocaleDateString(
locationSettings?.locale ?? 'en-US',
{
month: 'long',
year: 'numeric',
},
);
}
},
includedTraffic,
);
}, [theme, newPeriod]);
const traffic = useInstanceTrafficMetrics2(newPeriod);
const data = newToChartData(traffic.usage);
const [usageTotal, setUsageTotal] = useState<number>(0);
const [overageCost, setOverageCost] = useState<number>(0);
const [estimatedMonthlyCost, setEstimatedMonthlyCost] = useState<number>(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]);
return (
<ConditionallyRender
condition={isOss()}
show={<Alert severity='warning'>Not enabled.</Alert>}
elseShow={
<>
<ConditionallyRender
condition={includedTraffic > 0 && 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>
<NewHeader>
{
// todo: add new usage plan summary that works for monthly data as well as daily
}
<NetworkTrafficUsagePlanSummary
usageTotal={usageTotal}
includedTraffic={includedTraffic}
overageCost={overageCost}
estimatedMonthlyCost={estimatedMonthlyCost}
/>
<PeriodSelector
selectedPeriod={newPeriod}
setPeriod={setNewPeriod}
/>
</NewHeader>
<Bar
data={data}
plugins={[customHighlightPlugin()]} // todo: accomodate wide bars when grouping by month
options={options}
aria-label='An instance metrics line chart with two lines: requests per second for admin API and requests per second for client API' // todo: this isn't correct at all!
/>
</StyledBox>
</>
}
/>
);
};
export const NetworkTrafficUsage: FC = () => {
const useNewNetworkTraffic = useUiFlag('dataUsageMultiMonthView');
return useNewNetworkTraffic ? (
<NewNetworkTrafficUsage />
) : (
<OldNetworkTrafficUsage />
);
};
const OldNetworkTrafficUsage: FC = () => {
usePageTitle('Network - Data Usage'); usePageTitle('Network - Data Usage');
const theme = useTheme(); const theme = useTheme();
const showMultiMonthSelector = useUiFlag('dataUsageMultiMonthView');
const { isOss } = useUiConfig(); const { isOss } = useUiConfig();
@ -279,29 +448,13 @@ export const NetworkTrafficUsage: FC = () => {
} }
/> />
<StyledBox> <StyledBox>
{showMultiMonthSelector ? (
<NewHeader>
<NetworkTrafficUsagePlanSummary
usageTotal={usageTotal}
includedTraffic={includedTraffic}
overageCost={overageCost}
estimatedMonthlyCost={estimatedMonthlyCost}
/>
<PeriodSelector
selectedPeriod={period}
setPeriod={setPeriod}
/>
</NewHeader>
) : (
<Grid container component='header' spacing={2}> <Grid container component='header' spacing={2}>
<Grid item xs={12} md={10}> <Grid item xs={12} md={10}>
<NetworkTrafficUsagePlanSummary <NetworkTrafficUsagePlanSummary
usageTotal={usageTotal} usageTotal={usageTotal}
includedTraffic={includedTraffic} includedTraffic={includedTraffic}
overageCost={overageCost} overageCost={overageCost}
estimatedMonthlyCost={ estimatedMonthlyCost={estimatedMonthlyCost}
estimatedMonthlyCost
}
/> />
</Grid> </Grid>
<Grid item xs={12} md={2}> <Grid item xs={12} md={2}>
@ -310,9 +463,7 @@ export const NetworkTrafficUsage: FC = () => {
name='dataperiod' name='dataperiod'
options={selectablePeriods} options={selectablePeriods}
value={period} value={period}
onChange={(e) => onChange={(e) => setPeriod(e.target.value)}
setPeriod(e.target.value)
}
style={{ style={{
minWidth: '100%', minWidth: '100%',
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
@ -321,7 +472,7 @@ export const NetworkTrafficUsage: FC = () => {
/> />
</Grid> </Grid>
</Grid> </Grid>
)}
<Grid item xs={12} md={2}> <Grid item xs={12} md={2}>
<Bar <Bar
data={data} data={data}

View File

@ -1,5 +1,6 @@
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import { type FC, useState } from 'react'; import type { ChartDataSelection } from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics';
import type { FC } from 'react';
export type Period = { export type Period = {
key: string; key: string;
@ -147,25 +148,13 @@ type Selection =
}; };
type Props = { type Props = {
selectedPeriod: string; selectedPeriod: ChartDataSelection;
setPeriod: (period: string) => void; setPeriod: (period: ChartDataSelection) => void;
}; };
export const PeriodSelector: FC<Props> = ({ selectedPeriod, setPeriod }) => { export const PeriodSelector: FC<Props> = ({ selectedPeriod, setPeriod }) => {
const selectablePeriods = getSelectablePeriods(); const selectablePeriods = getSelectablePeriods();
// this is for dev purposes; only to show how the design will work when you select a range.
const [tempOverride, setTempOverride] = useState<Selection | null>();
const select = (value: Selection) => {
if (value.type === 'month') {
setTempOverride(null);
setPeriod(value.value);
} else {
setTempOverride(value);
}
};
const rangeOptions = [3, 6, 12].map((monthsBack) => ({ const rangeOptions = [3, 6, 12].map((monthsBack) => ({
value: monthsBack, value: monthsBack,
label: `Last ${monthsBack} months`, label: `Last ${monthsBack} months`,
@ -183,17 +172,17 @@ export const PeriodSelector: FC<Props> = ({ selectedPeriod, setPeriod }) => {
<li key={period.label}> <li key={period.label}>
<button <button
className={ className={
!tempOverride && selectedPeriod.grouping === 'daily' &&
period.key === selectedPeriod period.key === selectedPeriod.month
? 'selected' ? 'selected'
: '' : ''
} }
type='button' type='button'
disabled={!period.selectable} disabled={!period.selectable}
onClick={() => { onClick={() => {
select({ setPeriod({
type: 'month', grouping: 'daily',
value: period.key, month: period.key,
}); });
}} }}
> >
@ -211,16 +200,15 @@ export const PeriodSelector: FC<Props> = ({ selectedPeriod, setPeriod }) => {
<li key={option.label}> <li key={option.label}>
<button <button
className={ className={
tempOverride && selectedPeriod.grouping === 'monthly' &&
tempOverride.type === 'range' && option.value === selectedPeriod.monthsBack
option.value === tempOverride.monthsBack
? 'selected' ? 'selected'
: '' : ''
} }
type='button' type='button'
onClick={() => { onClick={() => {
select({ setPeriod({
type: 'range', grouping: 'monthly',
monthsBack: option.value, monthsBack: option.value,
}); });
}} }}

View File

@ -0,0 +1,22 @@
import { format } from 'date-fns';
import type { TrafficUsageDataSegmentedCombinedSchema } from 'openapi';
export const monthlyTrafficDataToCurrentUsage = (
usage?: TrafficUsageDataSegmentedCombinedSchema,
) => {
if (!usage) {
return 0;
}
const currentMonth = format(new Date(), 'yyyy-MM');
return usage.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);
};

View File

@ -2,7 +2,12 @@ import useSWR from 'swr';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler'; import handleErrorResponses from '../httpErrorResponseHandler';
import type { TrafficUsageDataSegmentedSchema } from 'openapi'; import type {
TrafficUsageDataSegmentedCombinedSchema,
TrafficUsageDataSegmentedCombinedSchemaApiDataItem,
TrafficUsageDataSegmentedSchema,
} from 'openapi';
import { endOfMonth, format, startOfMonth, subMonths } from 'date-fns';
export interface IInstanceTrafficMetricsResponse { export interface IInstanceTrafficMetricsResponse {
usage: TrafficUsageDataSegmentedSchema; usage: TrafficUsageDataSegmentedSchema;
@ -33,6 +38,64 @@ 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;
refetch: () => void;
loading: boolean;
error?: Error;
};
export const useInstanceTrafficMetrics2 = (
selection: ChartDataSelection,
): InstanceTrafficMetricsResponse2 => {
const { from, to } = fromSelection(selection);
const apiPath = `api/admin/metrics/traffic-search?grouping=${selection.grouping}&from=${from}&to=${to}`;
const { data, error, mutate } = useSWR(formatApiPath(apiPath), fetcher);
return useMemo(
() => ({
usage: data,
loading: !error && !data,
refetch: () => mutate(),
error,
}),
[data, error, mutate],
);
};
const fetcher = (path: string) => { const fetcher = (path: string) => {
return fetch(path) return fetch(path)
.then(handleErrorResponses('Instance Metrics')) .then(handleErrorResponses('Instance Metrics'))

View File

@ -1,6 +1,18 @@
import { useState } from 'react'; import { useState } from 'react';
import type { IInstanceTrafficMetricsResponse } from './api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics'; import type {
ChartDataSelection,
IInstanceTrafficMetricsResponse,
SegmentedSchemaApiData,
} from './api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics';
import type { ChartDataset } from 'chart.js'; 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_COST = 5;
const DEFAULT_TRAFFIC_DATA_UNIT_SIZE = 1_000_000; const DEFAULT_TRAFFIC_DATA_UNIT_SIZE = 1_000_000;
@ -104,6 +116,110 @@ const toPeriodsRecord = (
); );
}; };
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) =>
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 = ( const toChartData = (
days: number[], days: number[],
traffic: IInstanceTrafficMetricsResponse, traffic: IInstanceTrafficMetricsResponse,
@ -236,11 +352,18 @@ export const useTrafficDataEstimation = () => {
const record = toPeriodsRecord(selectablePeriods); const record = toPeriodsRecord(selectablePeriods);
const [period, setPeriod] = useState<string>(selectablePeriods[0].key); const [period, setPeriod] = useState<string>(selectablePeriods[0].key);
const [newPeriod, setNewPeriod] = useState<ChartDataSelection>({
grouping: 'daily',
month: selectablePeriods[0].key,
});
return { return {
calculateTrafficDataCost, calculateTrafficDataCost,
record, record,
period, period,
setPeriod, setPeriod,
newPeriod,
setNewPeriod,
selectablePeriods, selectablePeriods,
getDayLabels, getDayLabels,
currentPeriod, currentPeriod,