1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: traffic visibility UI and store (#6659)

Provides store method for retrieving traffic usage data based on
period parameter, and UI + ui hook with the new chart for displaying
traffic usage data spread out over selectable month.

![Skjermbilde 2024-03-21 kl 12 40
38](https://github.com/Unleash/unleash/assets/707867/539c6c98-b6f6-488a-97fb-baf4fccec687)

In this PR we copied and adapted a plugin written by DX for highlighting
a column in the chart:


![image](https://github.com/Unleash/unleash/assets/707867/70532b22-44ed-44c0-a9b4-75f65ed6a63d)

There are some minor improvements planned which will come in a separate
PR, reversing the order in legend and tooltip so the colors go from
light to dark, and adding a month -sum below the legend

## Discussion points

- Should any of this be extracted as a separate reusable component?

---------

Co-authored-by: Nuno Góis <github@nunogois.com>
This commit is contained in:
David Leek 2024-03-22 11:54:33 +01:00 committed by GitHub
parent 4b89c8a74a
commit e0994b088a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 576 additions and 14 deletions

View File

@ -7,6 +7,9 @@ import { PageContent } from 'component/common/PageContent/PageContent';
const NetworkOverview = lazy(() => import('./NetworkOverview/NetworkOverview'));
const NetworkTraffic = lazy(() => import('./NetworkTraffic/NetworkTraffic'));
const NetworkTrafficUsage = lazy(
() => import('./NetworkTrafficUsage/NetworkTrafficUsage'),
);
const tabs = [
{
@ -17,6 +20,10 @@ const tabs = [
label: 'Traffic',
path: '/admin/network/traffic',
},
{
label: 'Data Usage',
path: '/admin/network/data-usage',
},
];
export const Network = () => {
@ -52,6 +59,10 @@ export const Network = () => {
<Routes>
<Route path='traffic' element={<NetworkTraffic />} />
<Route path='*' element={<NetworkOverview />} />
<Route
path='data-usage'
element={<NetworkTrafficUsage />}
/>
</Routes>
</PageContent>
</div>

View File

@ -0,0 +1,392 @@
import { useMemo, type VFC, useState, useEffect } from 'react';
import useTheme from '@mui/material/styles/useTheme';
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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import {
Chart as ChartJS,
type ChartOptions,
CategoryScale,
LinearScale,
BarElement,
type ChartDataset,
Title,
Tooltip,
Legend,
type Chart,
type Tick,
} from 'chart.js';
import { Bar } from 'react-chartjs-2';
import {
type IInstanceTrafficMetricsResponse,
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';
type ChartDatasetType = ChartDataset<'bar'>;
type SelectablePeriod = {
key: string;
dayCount: number;
label: string;
year: number;
month: number;
};
type EndpointInfo = {
label: string;
color: string;
order: number;
};
const StyledHeader = styled('h3')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
fontSize: theme.fontSizes.bodySize,
margin: 0,
marginTop: theme.spacing(1),
fontWeight: theme.fontWeight.bold,
}));
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,
);
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) => {
const width = 36;
if (chart.tooltip?.opacity && chart.tooltip.x) {
const x = chart.tooltip.caretX;
const yAxis = chart.scales.y;
const ctx = chart.ctx;
ctx.save();
const gradient = ctx.createLinearGradient(
x,
yAxis.top,
x,
yAxis.bottom + 34,
);
gradient.addColorStop(0, 'rgba(129, 122, 254, 0)');
gradient.addColorStop(1, 'rgba(129, 122, 254, 0.12)');
ctx.fillStyle = gradient;
ctx.roundRect(
x - width / 2,
yAxis.top,
width,
yAxis.bottom - yAxis.top + 34,
5,
);
ctx.fill();
ctx.restore();
}
},
};
const createBarChartOptions = (
theme: Theme,
tooltipTitleCallback: (tooltipItems: any) => string,
): ChartOptions<'bar'> => ({
plugins: {
legend: {
position: 'bottom',
labels: {
color: theme.palette.text.primary,
pointStyle: 'circle',
usePointStyle: true,
boxHeight: 6,
padding: 15,
boxPadding: 5,
},
},
tooltip: {
backgroundColor: theme.palette.background.paper,
titleColor: theme.palette.text.primary,
bodyColor: theme.palette.text.primary,
bodySpacing: 6,
padding: {
top: 20,
bottom: 20,
left: 30,
right: 30,
},
borderColor: 'rgba(0, 0, 0, 0.05)',
borderWidth: 3,
usePointStyle: true,
caretSize: 0,
boxPadding: 10,
callbacks: {
title: tooltipTitleCallback,
},
},
},
responsive: true,
scales: {
x: {
stacked: true,
ticks: {
color: theme.palette.text.secondary,
},
grid: {
display: false,
},
},
y: {
stacked: true,
ticks: {
color: theme.palette.text.secondary,
maxTicksLimit: 5,
callback: (
tickValue: string | number,
index: number,
ticks: Tick[],
) => {
if (typeof tickValue === 'string') {
return tickValue;
}
const value = Number.parseInt(tickValue.toString());
if (value > 999999) {
return `${value / 1000000}M`;
}
return value > 999 ? `${value / 1000}k` : value;
},
},
grid: {
drawBorder: false,
},
},
},
elements: {
bar: {
borderRadius: 5,
},
},
interaction: {
mode: 'index',
intersect: false,
},
});
export const NetworkTrafficUsage: VFC = () => {
usePageTitle('Network - Data Usage');
const theme = useTheme();
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 selectablePeriods = getSelectablePeriods();
const record = toPeriodsRecord(selectablePeriods);
const [period, setPeriod] = useState<string>(selectablePeriods[0].key);
const options = useMemo(() => {
return createBarChartOptions(theme, (tooltipItems: any) => {
const periodItem = record[period];
const tooltipDate = new Date(
periodItem.year,
periodItem.month,
Number.parseInt(tooltipItems[0].label),
);
return tooltipDate.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
});
});
}, [theme, period]);
const traffic = useInstanceTrafficMetrics(period);
const [labels, setLabels] = useState<number[]>([]);
const [datasets, setDatasets] = useState<ChartDatasetType[]>([]);
const data = {
labels,
datasets,
};
const { isOss } = useUiConfig();
const flagEnabled = useUiFlag('collectTrafficDataUsage');
useEffect(() => {
setDatasets(toChartData(labels, traffic, endpointsInfo));
}, [labels, traffic]);
useEffect(() => {
if (record && period) {
const periodData = record[period];
setLabels(getDayLabels(periodData.dayCount));
}
}, [period]);
return (
<ConditionallyRender
condition={isOss() || !flagEnabled}
show={<Alert severity='warning'>No data available.</Alert>}
elseShow={
<>
<Grid container component='header' spacing={2}>
<Grid item xs={12} md={10}>
<StyledHeader>
Number of requests to Unleash
</StyledHeader>
</Grid>
<Grid item xs={12} md={2}>
<Select
id='dataperiod-select'
name='dataperiod'
options={selectablePeriods}
value={period}
onChange={(e) => setPeriod(e.target.value)}
style={{
minWidth: '100%',
marginBottom: theme.spacing(2),
}}
formControlStyles={{ width: '100%' }}
/>
</Grid>
</Grid>
<Box sx={{ display: 'grid', gap: 4 }}>
<div>
<Bar
data={data}
plugins={[customHighlightPlugin]}
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'
/>
</div>
</Box>
</>
}
/>
);
};
// Register dependencies that we need to draw the chart.
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
);
// Use a default export to lazy-load the charting library.
export default NetworkTrafficUsage;

View File

@ -0,0 +1,40 @@
import useSWR from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import type { TrafficUsageDataSegmentedSchema } from 'openapi';
export interface IInstanceTrafficMetricsResponse {
usage: TrafficUsageDataSegmentedSchema;
refetch: () => void;
loading: boolean;
error?: Error;
}
export const useInstanceTrafficMetrics = (
period: string,
): IInstanceTrafficMetricsResponse => {
const { data, error, mutate } = useSWR(
formatApiPath(`api/admin/metrics/traffic/${period}`),
fetcher,
);
return useMemo(
() => ({
usage: data,
loading: !error && !data,
refetch: () => mutate(),
error,
}),
[data, error, mutate],
);
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Instance Metrics'))
.then((res) => res.json());
};

View File

@ -80,6 +80,7 @@ export type UiFlags = {
sdkReporting?: boolean;
outdatedSdksBanner?: boolean;
projectOverviewRefactor?: string;
collectTrafficDataUsage?: boolean;
};
export interface IVersionInfo {

View File

@ -1076,6 +1076,10 @@ export * from './toggleMaintenance403';
export * from './toggleMaintenanceSchema';
export * from './tokenStringListSchema';
export * from './tokenUserSchema';
export * from './trafficUsageApiDataSchema';
export * from './trafficUsageApiDataSchemaDaysItem';
export * from './trafficUsageApiDataSchemaDaysItemTrafficTypesItem';
export * from './trafficUsageDataSegmentedSchema';
export * from './uiConfigSchema';
export * from './uiConfigSchemaAuthenticationType';
export * from './uiConfigSchemaFlags';

View File

@ -0,0 +1,16 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
import type { TrafficUsageApiDataSchemaDaysItem } from './trafficUsageApiDataSchemaDaysItem';
/**
* Contains the recorded data usage for each API path, segmented by day and type of traffic
*/
export interface TrafficUsageApiDataSchema {
/** The path of the API that the recorded data usage is for */
apiPath: string;
/** An array containing each day in the selected period that has data usage recorded */
days: TrafficUsageApiDataSchemaDaysItem[];
}

View File

@ -0,0 +1,13 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
import type { TrafficUsageApiDataSchemaDaysItemTrafficTypesItem } from './trafficUsageApiDataSchemaDaysItemTrafficTypesItem';
export type TrafficUsageApiDataSchemaDaysItem = {
/** The day of the period for which the usage is recorded */
day: string;
/** Contains the recorded data usage for each type of traffic group */
trafficTypes: TrafficUsageApiDataSchemaDaysItemTrafficTypesItem[];
};

View File

@ -0,0 +1,12 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type TrafficUsageApiDataSchemaDaysItemTrafficTypesItem = {
/** The number of requests */
count: number;
/** The traffic group */
group: string;
};

View File

@ -0,0 +1,16 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
import type { TrafficUsageApiDataSchema } from './trafficUsageApiDataSchema';
/**
* Contains the recorded data usage for each API path, segmented by day and type of traffic
*/
export interface TrafficUsageDataSegmentedSchema {
/** Contains the recorded daily data usage for each API path */
apiData: TrafficUsageApiDataSchema[];
/** The year-month period for which the data usage is counted */
period: string;
}

View File

@ -26,4 +26,7 @@ export class FakeTrafficDataUsageStore implements ITrafficDataUsageStore {
upsert(trafficDataUsage: IStatTrafficUsage): Promise<void> {
throw new Error('Method not implemented.');
}
getTrafficDataUsageForPeriod(period: string): Promise<IStatTrafficUsage[]> {
throw new Error('Method not implemented.');
}
}

View File

@ -4,7 +4,7 @@ export type IStatTrafficUsage = {
day: Date;
trafficGroup: string;
statusCodeSeries: number;
count: number | string;
count: number;
};
export interface IStatTrafficUsageKey {
@ -16,4 +16,5 @@ export interface IStatTrafficUsageKey {
export interface ITrafficDataUsageStore
extends Store<IStatTrafficUsage, IStatTrafficUsageKey> {
upsert(trafficDataUsage: IStatTrafficUsage): Promise<void>;
getTrafficDataUsageForPeriod(period: string): Promise<IStatTrafficUsage[]>;
}

View File

@ -25,7 +25,7 @@ test('upsert stores new entries', async () => {
day: new Date(),
trafficGroup: 'default',
statusCodeSeries: 200,
count: '1',
count: 1,
};
await trafficDataUsageStore.upsert(data);
const data2 = await trafficDataUsageStore.get({
@ -34,7 +34,7 @@ test('upsert stores new entries', async () => {
statusCodeSeries: data.statusCodeSeries,
});
expect(data2).toBeDefined();
expect(data2.count).toBe('1');
expect(data2.count).toBe(1);
});
test('upsert upserts', async () => {
@ -42,13 +42,13 @@ test('upsert upserts', async () => {
day: new Date(),
trafficGroup: 'default2',
statusCodeSeries: 200,
count: '1',
count: 1,
};
const dataSecondTime = {
day: new Date(),
trafficGroup: 'default2',
statusCodeSeries: 200,
count: '3',
count: 3,
};
await trafficDataUsageStore.upsert(data);
await trafficDataUsageStore.upsert(dataSecondTime);
@ -58,7 +58,7 @@ test('upsert upserts', async () => {
statusCodeSeries: data.statusCodeSeries,
});
expect(data2).toBeDefined();
expect(data2.count).toBe('4');
expect(data2.count).toBe(4);
});
test('getAll returns all', async () => {
@ -67,13 +67,13 @@ test('getAll returns all', async () => {
day: new Date(),
trafficGroup: 'default3',
statusCodeSeries: 200,
count: '1',
count: 1,
};
const data2 = {
day: new Date(),
trafficGroup: 'default4',
statusCodeSeries: 200,
count: '3',
count: 3,
};
await trafficDataUsageStore.upsert(data1);
await trafficDataUsageStore.upsert(data2);
@ -88,13 +88,13 @@ test('delete deletes the specified item', async () => {
day: new Date(),
trafficGroup: 'default3',
statusCodeSeries: 200,
count: '1',
count: 1,
};
const data2 = {
day: new Date(),
trafficGroup: 'default4',
statusCodeSeries: 200,
count: '3',
count: 3,
};
await trafficDataUsageStore.upsert(data1);
await trafficDataUsageStore.upsert(data2);
@ -115,19 +115,19 @@ test('can query for specific items', async () => {
day: new Date(),
trafficGroup: 'default3',
statusCodeSeries: 200,
count: '1',
count: 1,
};
const data2 = {
day: new Date(),
trafficGroup: 'default4',
statusCodeSeries: 200,
count: '3',
count: 3,
};
const data3 = {
day: new Date(),
trafficGroup: 'default5',
statusCodeSeries: 200,
count: '2',
count: 2,
};
await trafficDataUsageStore.upsert(data1);
await trafficDataUsageStore.upsert(data2);
@ -152,3 +152,46 @@ test('can query for specific items', async () => {
expect(results_status_code).toBeDefined();
expect(results_status_code.length).toBe(3);
});
test('can query for data from specific periods', async () => {
await trafficDataUsageStore.deleteAll();
const data1 = {
day: new Date(2024, 2, 12),
trafficGroup: 'default-period-query',
statusCodeSeries: 200,
count: 1,
};
const data2 = {
day: new Date(2024, 2, 13),
trafficGroup: 'default-period-query',
statusCodeSeries: 200,
count: 3,
};
const data3 = {
day: new Date(2024, 1, 12),
trafficGroup: 'default-period-query',
statusCodeSeries: 200,
count: 2,
};
const data4 = {
day: new Date(2023, 9, 6),
trafficGroup: 'default-period-query',
statusCodeSeries: 200,
count: 12,
};
await trafficDataUsageStore.upsert(data1);
await trafficDataUsageStore.upsert(data2);
await trafficDataUsageStore.upsert(data3);
await trafficDataUsageStore.upsert(data4);
const traffic_period_usage =
await trafficDataUsageStore.getTrafficDataUsageForPeriod('2024-03');
expect(traffic_period_usage).toBeDefined();
expect(traffic_period_usage.length).toBe(2);
const traffic_period_usage_older =
await trafficDataUsageStore.getTrafficDataUsageForPeriod('2023-10');
expect(traffic_period_usage_older).toBeDefined();
expect(traffic_period_usage_older.length).toBe(1);
expect(traffic_period_usage_older[0].count).toBe(12);
});

View File

@ -23,7 +23,7 @@ const mapRow = (row: any): IStatTrafficUsage => {
day: row.day,
trafficGroup: row.traffic_group,
statusCodeSeries: row.status_code_series,
count: row.count,
count: Number.parseInt(row.count),
};
};
@ -87,4 +87,14 @@ export class TrafficDataUsageStore implements ITrafficDataUsageStore {
count: this.db.raw('stat_traffic_usage.count + EXCLUDED.count'),
});
}
async getTrafficDataUsageForPeriod(
period: string,
): Promise<IStatTrafficUsage[]> {
const rows = await this.db<IStatTrafficUsage>(TABLE).whereRaw(
`to_char(day, 'YYYY-MM') = ?`,
[period],
);
return rows.map(mapRow);
}
}