Skip to content

Commit

Permalink
feat(TenantOverview): add charts (#657)
Browse files Browse the repository at this point in the history
  • Loading branch information
artemmufazalov authored Jan 25, 2024
1 parent f816d60 commit 78daa0b
Show file tree
Hide file tree
Showing 38 changed files with 1,333 additions and 148 deletions.
620 changes: 496 additions & 124 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
},
"dependencies": {
"@gravity-ui/axios-wrapper": "^1.3.0",
"@gravity-ui/chartkit": "^4.15.0",
"@gravity-ui/components": "^2.9.1",
"@gravity-ui/date-utils": "^1.1.1",
"@gravity-ui/i18n": "^1.0.0",
Expand Down Expand Up @@ -45,6 +46,7 @@
"reselect": "4.1.6",
"sass": "1.32.8",
"url": "^0.11.0",
"use-query-params": "^2.2.1",
"web-vitals": "1.1.2",
"ydb-ui-components": "^3.6.0"
},
Expand Down
34 changes: 34 additions & 0 deletions src/components/MetricChart/MetricChart.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
.ydb-metric-chart {
display: flex;
flex-direction: column;

padding: 16px 16px 8px;

border: 1px solid var(--g-color-line-generic);
border-radius: 8px;

&__title {
margin-bottom: 10px;
}

&__chart {
position: relative;

display: flex;
overflow: hidden;

width: 100%;
height: 100%;
}

&__error {
position: absolute;
z-index: 1;
top: 10%;
left: 50%;

text-align: center;

transform: translateX(-50%);
}
}
198 changes: 198 additions & 0 deletions src/components/MetricChart/MetricChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import {useCallback, useEffect, useReducer, useRef} from 'react';

import {RawSerieData, YagrPlugin, YagrWidgetData} from '@gravity-ui/chartkit/yagr';
import ChartKit, {settings} from '@gravity-ui/chartkit';

import type {IResponseError} from '../../types/api/error';
import type {TimeFrame} from '../../utils/timeframes';
import {useAutofetcher} from '../../utils/hooks';
import {COLORS} from '../../utils/versions';
import {cn} from '../../utils/cn';

import {Loader} from '../Loader';
import {ResponseError} from '../Errors/ResponseError';

import type {ChartOptions, MetricDescription, PreparedMetricsData} from './types';
import {convertResponse} from './convertReponse';
import {getDefaultDataFormatter} from './getDefaultDataFormatter';
import {getChartData} from './getChartData';
import {
chartReducer,
initialChartState,
setChartData,
setChartDataLoading,
setChartDataWasNotLoaded,
setChartError,
} from './reducer';

import './MetricChart.scss';

const b = cn('ydb-metric-chart');

settings.set({plugins: [YagrPlugin]});

const prepareWidgetData = (
data: PreparedMetricsData,
options: ChartOptions = {},
): YagrWidgetData => {
const {dataType} = options;
const defaultDataFormatter = getDefaultDataFormatter(dataType);

const isDataEmpty = !data.metrics.length;

const graphs: RawSerieData[] = data.metrics.map((metric, index) => {
return {
id: metric.target,
name: metric.title || metric.target,
color: metric.color || COLORS[index],
data: metric.data,
formatter: defaultDataFormatter,
};
});

return {
data: {
timeline: data.timeline,
graphs,
},

libraryConfig: {
chart: {
size: {
// When empty data chart is displayed without axes it have different paddings
// To compensate it, additional paddings are applied
padding: isDataEmpty ? [10, 0, 10, 0] : undefined,
},
series: {
type: 'line',
},
select: {
zoom: false,
},
},
scales: {
y: {
type: 'linear',
range: 'nice',
},
},
axes: {
y: {
values: defaultDataFormatter
? (_, ticks) => ticks.map(defaultDataFormatter)
: undefined,
},
},
tooltip: {
show: true,
tracking: 'sticky',
},
},
};
};

interface DiagnosticsChartProps {
title?: string;
metrics: MetricDescription[];
timeFrame?: TimeFrame;

autorefresh?: boolean;

height?: number;
width?: number;

chartOptions?: ChartOptions;
}

export const MetricChart = ({
title,
metrics,
timeFrame = '1h',
autorefresh,
width = 400,
height = width / 1.5,
chartOptions,
}: DiagnosticsChartProps) => {
const mounted = useRef(false);

useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);

const [{loading, wasLoaded, data, error}, dispatch] = useReducer(
chartReducer,
initialChartState,
);

const fetchChartData = useCallback(
async (isBackground: boolean) => {
dispatch(setChartDataLoading());

if (!isBackground) {
dispatch(setChartDataWasNotLoaded());
}

try {
// maxDataPoints param is calculated based on width
// should be width > maxDataPoints to prevent points that cannot be selected
// more px per dataPoint - easier to select, less - chart is smoother
const response = await getChartData({
metrics,
timeFrame,
maxDataPoints: width / 2,
});

// Hack to prevent setting value to state, if component unmounted
if (!mounted.current) return;

// In some cases error could be in response with 200 status code
// It happens when request is OK, but chart data cannot be returned due to some reason
// Example: charts are not enabled in the DB ('GraphShard is not enabled' error)
if (Array.isArray(response)) {
const preparedData = convertResponse(response, metrics);
dispatch(setChartData(preparedData));
} else {
dispatch(setChartError({statusText: response.error}));
}
} catch (err) {
if (!mounted.current) return;

dispatch(setChartError(err as IResponseError));
}
},
[metrics, timeFrame, width],
);

useAutofetcher(fetchChartData, [fetchChartData], autorefresh);

const convertedData = prepareWidgetData(data, chartOptions);

const renderContent = () => {
if (loading && !wasLoaded) {
return <Loader />;
}

return (
<div className={b('chart')}>
<ChartKit type="yagr" data={convertedData} />
{error && <ResponseError className={b('error')} error={error} />}
</div>
);
};

return (
<div
className={b(null)}
style={{
height,
width,
}}
>
<div className={b('title')}>{title}</div>
{renderContent()}
</div>
);
};
32 changes: 32 additions & 0 deletions src/components/MetricChart/convertReponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type {MetricData} from '../../types/api/render';
import type {MetricDescription, PreparedMetric, PreparedMetricsData} from './types';

export const convertResponse = (
data: MetricData[] = [],
metrics: MetricDescription[],
): PreparedMetricsData => {
const preparedMetrics = data
.map(({datapoints, target}) => {
const metricDescription = metrics.find((metric) => metric.target === target);
const chartData = datapoints.map((datapoint) => datapoint[0] || 0);

if (!metricDescription) {
return undefined;
}

return {
...metricDescription,
data: chartData,
};
})
.filter((metric): metric is PreparedMetric => metric !== undefined);

// Asuming all metrics in response have the same timeline
// Backend return data in seconds, while chart needs ms
const timeline = data[0].datapoints.map((datapoint) => datapoint[1] * 1000);

return {
timeline,
metrics: preparedMetrics,
};
};
20 changes: 20 additions & 0 deletions src/components/MetricChart/getChartData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {TIMEFRAMES, type TimeFrame} from '../../utils/timeframes';
import type {MetricDescription} from './types';

interface GetChartDataParams {
metrics: MetricDescription[];
timeFrame: TimeFrame;
maxDataPoints: number;
}

export const getChartData = async ({metrics, timeFrame, maxDataPoints}: GetChartDataParams) => {
const targetString = metrics.map((metric) => `target=${metric.target}`).join('&');

const until = Math.round(Date.now() / 1000);
const from = until - TIMEFRAMES[timeFrame];

return window.api.getChartData(
{target: targetString, from, until, maxDataPoints},
{concurrentId: `getChartData|${targetString}`},
);
};
36 changes: 36 additions & 0 deletions src/components/MetricChart/getDefaultDataFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {formatBytes} from '../../utils/bytesParsers';
import {roundToPrecision} from '../../utils/dataFormatters/dataFormatters';
import {formatToMs} from '../../utils/timeParsers';
import {isNumeric} from '../../utils/utils';

import type {ChartDataType, ChartValue} from './types';

export const getDefaultDataFormatter = (dataType?: ChartDataType) => {
switch (dataType) {
case 'ms': {
return formatChartValueToMs;
}
case 'size': {
return formatChartValueToSize;
}
default:
return undefined;
}
};

function formatChartValueToMs(value: ChartValue) {
return formatToMs(roundToPrecision(convertToNumber(value), 2));
}

function formatChartValueToSize(value: ChartValue) {
return formatBytes({value: convertToNumber(value), precision: 3});
}

// Numeric values expected, not numeric value should be displayd as 0
function convertToNumber(value: unknown): number {
if (isNumeric(value)) {
return Number(value);
}

return 0;
}
2 changes: 2 additions & 0 deletions src/components/MetricChart/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type {MetricDescription, Metric, ChartOptions} from './types';
export {MetricChart} from './MetricChart';
Loading

0 comments on commit 78daa0b

Please sign in to comment.