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

feat: first draft of chart for instance traffic in frontend (#2670)

## What
We've already added the backend for this. This is the initial work for
drawing a chart for instance traffic in the frontend. It requires the environment variable `PROMETHEUS_API` set to a valid prometheus-query-language (promql) supported backend, such as Prometheus itself or Victoria Metrics. Besides, at the moment we're hiding this functionality behind the flag `UNLEASH_EXPERIMENTAL_NETWORK_VIEW` which has to be set to true

Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>
Co-authored-by: Gastón Fournier <gaston@getunleash.ai>
This commit is contained in:
Christopher Kolstad 2022-12-15 09:47:07 +01:00 committed by GitHub
parent 8e5ca352c3
commit 23094b016e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 760 additions and 1 deletions

View File

@ -87,6 +87,16 @@ function AdminMenu() {
</CenteredNavLink>
}
/>
{flags.networkView && (
<Tab
value="/admin/traffic"
label={
<CenteredNavLink to="/admin/traffic">
Traffic
</CenteredNavLink>
}
/>
)}
{isBilling && (
<Tab
value="/admin/billing"

View File

@ -0,0 +1,11 @@
import InstanceMetricsChart from './TrafficMetricsChart';
import AdminMenu from '../menu/AdminMenu';
export const Traffic = () => {
return (
<div>
<AdminMenu />
<InstanceMetricsChart />
</div>
);
};

View File

@ -0,0 +1,212 @@
import {
InstanceMetrics,
useInstanceMetrics,
} from '../../../hooks/api/getters/useInstanceMetrics/useInstanceMetrics';
import { useMemo, VFC } from 'react';
import { Line } from 'react-chartjs-2';
import {
CategoryScale,
Chart as ChartJS,
ChartData,
ChartDataset,
ChartOptions,
Legend,
LinearScale,
LineElement,
PointElement,
TimeScale,
Title,
Tooltip,
} from 'chart.js';
import {
ILocationSettings,
useLocationSettings,
} from '../../../hooks/useLocationSettings';
import theme from '../../../themes/theme';
import { formatDateHM } from '../../../utils/formatDate';
import { RequestsPerSecondSchema } from 'openapi';
import 'chartjs-adapter-date-fns';
import { PaletteColor } from '@mui/material';
import { PageContent } from '../../common/PageContent/PageContent';
import { PageHeader } from '../../common/PageHeader/PageHeader';
import { Box } from '@mui/system';
import { current } from 'immer';
import { CyclicIterator } from 'utils/cyclicIterator';
interface IPoint {
x: number;
y: number;
}
type ChartDatasetType = ChartDataset<'line', IPoint[]>;
type ChartDataType = ChartData<'line', IPoint[], string>;
const createChartPoints = (
values: Array<Array<number | string>>,
y: (m: string) => number
): IPoint[] => {
return values.map(row => ({
x: row[0] as number,
y: y(row[1] as string),
}));
};
const createInstanceChartOptions = (
metrics: InstanceMetrics,
locationSettings: ILocationSettings
): ChartOptions<'line'> => {
return {
locale: locationSettings.locale,
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
tooltip: {
backgroundColor: 'white',
bodyColor: theme.palette.text.primary,
titleColor: theme.palette.grey[700],
borderColor: theme.palette.primary.main,
borderWidth: 1,
padding: 10,
boxPadding: 5,
usePointStyle: true,
callbacks: {
title: items =>
formatDateHM(
1000 * items[0].parsed.x,
locationSettings.locale
),
},
itemSort: (a, b) => b.parsed.y - a.parsed.y,
},
legend: {
position: 'top',
align: 'end',
labels: {
boxWidth: 10,
boxHeight: 10,
usePointStyle: true,
},
},
title: {
text: 'Requests per second in the last 6 hours',
position: 'top',
align: 'start',
display: true,
font: {
size: 16,
weight: '400',
},
},
},
scales: {
y: {
type: 'linear',
title: {
display: true,
text: 'Requests per second',
},
// min: 0,
suggestedMin: 0,
ticks: { precision: 0 },
},
x: {
type: 'time',
time: { unit: 'minute' },
grid: { display: false },
ticks: {
callback: (_, i, data) =>
formatDateHM(data[i].value, locationSettings.locale),
},
},
},
};
};
const toChartData = (
rps: RequestsPerSecondSchema,
color: PaletteColor,
label: (name: string) => string
): ChartDatasetType[] => {
if (rps.data?.result) {
return rps.data.result.map(dataset => ({
label: label(dataset.metric?.appName || 'unknown'),
borderColor: color.main,
backgroundColor: color.main,
data: createChartPoints(dataset.values || [], y => parseFloat(y)),
elements: {
point: {
radius: 4,
pointStyle: 'circle',
},
line: {
borderDash: [8, 4],
},
},
}));
}
return [];
};
const createInstanceChartData = (metrics?: InstanceMetrics): ChartDataType => {
if (metrics) {
const colors = new CyclicIterator<PaletteColor>([
theme.palette.success,
theme.palette.error,
theme.palette.primary,
]);
let datasets: ChartDatasetType[] = [];
for (let key in metrics) {
datasets = datasets.concat(
toChartData(
metrics[key],
colors.next(),
metricName => `${metricName}: ${key}`
)
);
}
return { datasets };
}
return { datasets: [] };
};
export const InstanceMetricsChart: VFC = () => {
const { locationSettings } = useLocationSettings();
const { metrics } = useInstanceMetrics();
const options = useMemo(() => {
return createInstanceChartOptions(metrics, locationSettings);
}, [metrics, locationSettings]);
const data = useMemo(() => {
return createInstanceChartData(metrics);
}, [metrics, locationSettings]);
return (
<PageContent header={<PageHeader title="Requests per second" />}>
<Box sx={{ display: 'grid', gap: 4 }}>
<div style={{ height: 400 }}>
<Line
data={data}
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>
</PageContent>
);
};
// Register dependencies that we need to draw the chart.
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
TimeScale,
Legend,
Tooltip,
Title
);
// Use a default export to lazy-load the charting library.
export default InstanceMetricsChart;

View File

@ -465,6 +465,17 @@ exports[`returns all baseRoutes 1`] = `
"title": "Instance stats",
"type": "protected",
},
{
"component": [Function],
"flag": "networkView",
"menu": {
"adminSettings": true,
},
"parent": "/admin",
"path": "/admin/traffic",
"title": "Traffic",
"type": "protected",
},
{
"component": [Function],
"flag": "embedProxyFrontend",

View File

@ -60,6 +60,7 @@ import { CorsAdmin } from 'component/admin/cors';
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
import { Profile } from 'component/user/Profile/Profile';
import { InstanceAdmin } from '../admin/instance-admin/InstanceAdmin';
import { Traffic } from 'component/admin/traffic/Traffic';
export const routes: IRoute[] = [
// Splash
@ -509,6 +510,15 @@ export const routes: IRoute[] = [
type: 'protected',
menu: { adminSettings: true },
},
{
path: '/admin/traffic',
parent: '/admin',
title: 'Traffic',
component: Traffic,
type: 'protected',
menu: { adminSettings: true },
flag: 'networkView',
},
{
path: '/admin/cors',
parent: '/admin',

View File

@ -0,0 +1,42 @@
import useSWR from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { RequestsPerSecondSchema } from 'openapi';
export interface InstanceMetrics {
[key: string]: RequestsPerSecondSchema;
}
export interface IInstanceMetricsResponse {
metrics: InstanceMetrics;
refetch: () => void;
loading: boolean;
error?: Error;
}
export const useInstanceMetrics = (): IInstanceMetricsResponse => {
const { data, error, mutate } = useSWR(
formatApiPath(`api/admin/metrics/rps`),
fetcher
);
return useMemo(
() => ({
metrics: 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

@ -0,0 +1,80 @@
/* tslint:disable */
/* eslint-disable */
/**
* Unleash API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 4.19.0-beta.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { exists, mapValues } from '../runtime';
import type { RequestsPerSecondSchemaData } from './RequestsPerSecondSchemaData';
import {
RequestsPerSecondSchemaDataFromJSON,
RequestsPerSecondSchemaDataFromJSONTyped,
RequestsPerSecondSchemaDataToJSON,
} from './RequestsPerSecondSchemaData';
/**
*
* @export
* @interface RequestsPerSecondSchema
*/
export interface RequestsPerSecondSchema {
/**
*
* @type {string}
* @memberof RequestsPerSecondSchema
*/
status?: string;
/**
*
* @type {RequestsPerSecondSchemaData}
* @memberof RequestsPerSecondSchema
*/
data?: RequestsPerSecondSchemaData;
}
/**
* Check if a given object implements the RequestsPerSecondSchema interface.
*/
export function instanceOfRequestsPerSecondSchema(value: object): boolean {
let isInstance = true;
return isInstance;
}
export function RequestsPerSecondSchemaFromJSON(json: any): RequestsPerSecondSchema {
return RequestsPerSecondSchemaFromJSONTyped(json, false);
}
export function RequestsPerSecondSchemaFromJSONTyped(json: any, ignoreDiscriminator: boolean): RequestsPerSecondSchema {
if ((json === undefined) || (json === null)) {
return json;
}
return {
'status': !exists(json, 'status') ? undefined : json['status'],
'data': !exists(json, 'data') ? undefined : RequestsPerSecondSchemaDataFromJSON(json['data']),
};
}
export function RequestsPerSecondSchemaToJSON(value?: RequestsPerSecondSchema | null): any {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
return {
'status': value.status,
'data': RequestsPerSecondSchemaDataToJSON(value.data),
};
}

View File

@ -0,0 +1,80 @@
/* tslint:disable */
/* eslint-disable */
/**
* Unleash API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 4.19.0-beta.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { exists, mapValues } from '../runtime';
import type { RequestsPerSecondSchemaDataResultInner } from './RequestsPerSecondSchemaDataResultInner';
import {
RequestsPerSecondSchemaDataResultInnerFromJSON,
RequestsPerSecondSchemaDataResultInnerFromJSONTyped,
RequestsPerSecondSchemaDataResultInnerToJSON,
} from './RequestsPerSecondSchemaDataResultInner';
/**
*
* @export
* @interface RequestsPerSecondSchemaData
*/
export interface RequestsPerSecondSchemaData {
/**
*
* @type {string}
* @memberof RequestsPerSecondSchemaData
*/
resultType?: string;
/**
* An array of values per metric. Each one represents a line in the graph labeled by its metric name
* @type {Array<RequestsPerSecondSchemaDataResultInner>}
* @memberof RequestsPerSecondSchemaData
*/
result?: Array<RequestsPerSecondSchemaDataResultInner>;
}
/**
* Check if a given object implements the RequestsPerSecondSchemaData interface.
*/
export function instanceOfRequestsPerSecondSchemaData(value: object): boolean {
let isInstance = true;
return isInstance;
}
export function RequestsPerSecondSchemaDataFromJSON(json: any): RequestsPerSecondSchemaData {
return RequestsPerSecondSchemaDataFromJSONTyped(json, false);
}
export function RequestsPerSecondSchemaDataFromJSONTyped(json: any, ignoreDiscriminator: boolean): RequestsPerSecondSchemaData {
if ((json === undefined) || (json === null)) {
return json;
}
return {
'resultType': !exists(json, 'resultType') ? undefined : json['resultType'],
'result': !exists(json, 'result') ? undefined : ((json['result'] as Array<any>).map(RequestsPerSecondSchemaDataResultInnerFromJSON)),
};
}
export function RequestsPerSecondSchemaDataToJSON(value?: RequestsPerSecondSchemaData | null): any {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
return {
'resultType': value.resultType,
'result': value.result === undefined ? undefined : ((value.result as Array<any>).map(RequestsPerSecondSchemaDataResultInnerToJSON)),
};
}

View File

@ -0,0 +1,86 @@
/* tslint:disable */
/* eslint-disable */
/**
* Unleash API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 4.19.0-beta.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { exists, mapValues } from '../runtime';
import type { RequestsPerSecondSchemaDataResultInnerMetric } from './RequestsPerSecondSchemaDataResultInnerMetric';
import {
RequestsPerSecondSchemaDataResultInnerMetricFromJSON,
RequestsPerSecondSchemaDataResultInnerMetricFromJSONTyped,
RequestsPerSecondSchemaDataResultInnerMetricToJSON,
} from './RequestsPerSecondSchemaDataResultInnerMetric';
import type { RequestsPerSecondSchemaDataResultInnerValuesInnerInner } from './RequestsPerSecondSchemaDataResultInnerValuesInnerInner';
import {
RequestsPerSecondSchemaDataResultInnerValuesInnerInnerFromJSON,
RequestsPerSecondSchemaDataResultInnerValuesInnerInnerFromJSONTyped,
RequestsPerSecondSchemaDataResultInnerValuesInnerInnerToJSON,
} from './RequestsPerSecondSchemaDataResultInnerValuesInnerInner';
/**
*
* @export
* @interface RequestsPerSecondSchemaDataResultInner
*/
export interface RequestsPerSecondSchemaDataResultInner {
/**
*
* @type {RequestsPerSecondSchemaDataResultInnerMetric}
* @memberof RequestsPerSecondSchemaDataResultInner
*/
metric?: RequestsPerSecondSchemaDataResultInnerMetric;
/**
* An array of arrays. Each element of the array is an array of size 2 consisting of the 2 axis for the graph: in position zero the x axis represented as a number and position one the y axis represented as string
* @type {Array<Array<RequestsPerSecondSchemaDataResultInnerValuesInnerInner>>}
* @memberof RequestsPerSecondSchemaDataResultInner
*/
values?: Array<Array<RequestsPerSecondSchemaDataResultInnerValuesInnerInner>>;
}
/**
* Check if a given object implements the RequestsPerSecondSchemaDataResultInner interface.
*/
export function instanceOfRequestsPerSecondSchemaDataResultInner(value: object): boolean {
let isInstance = true;
return isInstance;
}
export function RequestsPerSecondSchemaDataResultInnerFromJSON(json: any): RequestsPerSecondSchemaDataResultInner {
return RequestsPerSecondSchemaDataResultInnerFromJSONTyped(json, false);
}
export function RequestsPerSecondSchemaDataResultInnerFromJSONTyped(json: any, ignoreDiscriminator: boolean): RequestsPerSecondSchemaDataResultInner {
if ((json === undefined) || (json === null)) {
return json;
}
return {
'metric': !exists(json, 'metric') ? undefined : RequestsPerSecondSchemaDataResultInnerMetricFromJSON(json['metric']),
'values': !exists(json, 'values') ? undefined : json['values'],
};
}
export function RequestsPerSecondSchemaDataResultInnerToJSON(value?: RequestsPerSecondSchemaDataResultInner | null): any {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
return {
'metric': RequestsPerSecondSchemaDataResultInnerMetricToJSON(value.metric),
'values': value.values,
};
}

View File

@ -0,0 +1,65 @@
/* tslint:disable */
/* eslint-disable */
/**
* Unleash API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 4.19.0-beta.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { exists, mapValues } from '../runtime';
/**
* A key value set representing the metric
* @export
* @interface RequestsPerSecondSchemaDataResultInnerMetric
*/
export interface RequestsPerSecondSchemaDataResultInnerMetric {
/**
*
* @type {string}
* @memberof RequestsPerSecondSchemaDataResultInnerMetric
*/
appName?: string;
}
/**
* Check if a given object implements the RequestsPerSecondSchemaDataResultInnerMetric interface.
*/
export function instanceOfRequestsPerSecondSchemaDataResultInnerMetric(value: object): boolean {
let isInstance = true;
return isInstance;
}
export function RequestsPerSecondSchemaDataResultInnerMetricFromJSON(json: any): RequestsPerSecondSchemaDataResultInnerMetric {
return RequestsPerSecondSchemaDataResultInnerMetricFromJSONTyped(json, false);
}
export function RequestsPerSecondSchemaDataResultInnerMetricFromJSONTyped(json: any, ignoreDiscriminator: boolean): RequestsPerSecondSchemaDataResultInnerMetric {
if ((json === undefined) || (json === null)) {
return json;
}
return {
'appName': !exists(json, 'appName') ? undefined : json['appName'],
};
}
export function RequestsPerSecondSchemaDataResultInnerMetricToJSON(value?: RequestsPerSecondSchemaDataResultInnerMetric | null): any {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
return {
'appName': value.appName,
};
}

View File

@ -0,0 +1,43 @@
/* tslint:disable */
/* eslint-disable */
/**
* Unleash API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 4.19.0-beta.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { exists, mapValues } from '../runtime';
/**
*
* @export
* @interface RequestsPerSecondSchemaDataResultInnerValuesInnerInner
*/
export type RequestsPerSecondSchemaDataResultInnerValuesInnerInner = number | string;
/**
* Check if a given object implements the RequestsPerSecondSchemaDataResultInnerValuesInnerInner interface.
*/
export function instanceOfRequestsPerSecondSchemaDataResultInnerValuesInnerInner(value: object): boolean {
let isInstance = true;
return isInstance;
}
export function RequestsPerSecondSchemaDataResultInnerValuesInnerInnerFromJSON(json: any): RequestsPerSecondSchemaDataResultInnerValuesInnerInner {
return RequestsPerSecondSchemaDataResultInnerValuesInnerInnerFromJSONTyped(json, false);
}
export function RequestsPerSecondSchemaDataResultInnerValuesInnerInnerFromJSONTyped(json: any, ignoreDiscriminator: boolean): RequestsPerSecondSchemaDataResultInnerValuesInnerInner {
return json;
}
export function RequestsPerSecondSchemaDataResultInnerValuesInnerInnerToJSON(value?: RequestsPerSecondSchemaDataResultInnerValuesInnerInner | null): any {
return value;
}

View File

@ -0,0 +1,80 @@
/* tslint:disable */
/* eslint-disable */
/**
* Unleash API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 4.19.0-beta.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { exists, mapValues } from '../runtime';
import type { RequestsPerSecondSchema } from './RequestsPerSecondSchema';
import {
RequestsPerSecondSchemaFromJSON,
RequestsPerSecondSchemaFromJSONTyped,
RequestsPerSecondSchemaToJSON,
} from './RequestsPerSecondSchema';
/**
*
* @export
* @interface RequestsPerSecondSegmentedSchema
*/
export interface RequestsPerSecondSegmentedSchema {
/**
*
* @type {RequestsPerSecondSchema}
* @memberof RequestsPerSecondSegmentedSchema
*/
clientMetrics?: RequestsPerSecondSchema;
/**
*
* @type {RequestsPerSecondSchema}
* @memberof RequestsPerSecondSegmentedSchema
*/
adminMetrics?: RequestsPerSecondSchema;
}
/**
* Check if a given object implements the RequestsPerSecondSegmentedSchema interface.
*/
export function instanceOfRequestsPerSecondSegmentedSchema(value: object): boolean {
let isInstance = true;
return isInstance;
}
export function RequestsPerSecondSegmentedSchemaFromJSON(json: any): RequestsPerSecondSegmentedSchema {
return RequestsPerSecondSegmentedSchemaFromJSONTyped(json, false);
}
export function RequestsPerSecondSegmentedSchemaFromJSONTyped(json: any, ignoreDiscriminator: boolean): RequestsPerSecondSegmentedSchema {
if ((json === undefined) || (json === null)) {
return json;
}
return {
'clientMetrics': !exists(json, 'clientMetrics') ? undefined : RequestsPerSecondSchemaFromJSON(json['clientMetrics']),
'adminMetrics': !exists(json, 'adminMetrics') ? undefined : RequestsPerSecondSchemaFromJSON(json['adminMetrics']),
};
}
export function RequestsPerSecondSegmentedSchemaToJSON(value?: RequestsPerSecondSegmentedSchema | null): any {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
return {
'clientMetrics': RequestsPerSecondSchemaToJSON(value.clientMetrics),
'adminMetrics': RequestsPerSecondSchemaToJSON(value.adminMetrics),
};
}

View File

@ -17,3 +17,5 @@ export * from './UpdateFeatureSchema';
export * from './UpdateStrategySchema';
export * from './VariantSchema';
export * from './VariantSchemaPayload';
export * from './RequestsPerSecondSchema';
export * from './RequestsPerSecondSchemaDataResultInnerValuesInnerInner';

View File

@ -0,0 +1,14 @@
import { CyclicIterator } from './cyclicIterator';
test('loops around the list', () => {
const iterator = new CyclicIterator<number>([1, 3, 5, 7]);
expect(iterator.next()).toEqual(1);
expect(iterator.next()).toEqual(3);
expect(iterator.next()).toEqual(5);
expect(iterator.next()).toEqual(7);
expect(iterator.next()).toEqual(1);
expect(iterator.next()).toEqual(3);
expect(iterator.next()).toEqual(5);
expect(iterator.next()).toEqual(7);
expect(iterator.next()).toEqual(1);
});

View File

@ -0,0 +1,12 @@
export class CyclicIterator<T> {
private current = 0;
readonly all: T[];
constructor(defaultList: T[]) {
this.all = defaultList;
}
next(): T {
const item = this.all[this.current];
this.current = (this.current + 1) % this.all.length;
return item;
}
}

View File

@ -201,7 +201,8 @@ class MetricsController extends Controller {
clientMetrics,
adminMetrics,
});
} catch (e) {
} catch (err) {
this.logger.error('Failed to fetch RPS metrics', err);
res.status(500).send('Error fetching RPS metrics');
}
}