Skip to content

Commit 78daa0b

Browse files
feat(TenantOverview): add charts (#657)
1 parent f816d60 commit 78daa0b

38 files changed

+1333
-148
lines changed

package-lock.json

Lines changed: 496 additions & 124 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@gravity-ui/axios-wrapper": "^1.3.0",
13+
"@gravity-ui/chartkit": "^4.15.0",
1314
"@gravity-ui/components": "^2.9.1",
1415
"@gravity-ui/date-utils": "^1.1.1",
1516
"@gravity-ui/i18n": "^1.0.0",
@@ -45,6 +46,7 @@
4546
"reselect": "4.1.6",
4647
"sass": "1.32.8",
4748
"url": "^0.11.0",
49+
"use-query-params": "^2.2.1",
4850
"web-vitals": "1.1.2",
4951
"ydb-ui-components": "^3.6.0"
5052
},
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
.ydb-metric-chart {
2+
display: flex;
3+
flex-direction: column;
4+
5+
padding: 16px 16px 8px;
6+
7+
border: 1px solid var(--g-color-line-generic);
8+
border-radius: 8px;
9+
10+
&__title {
11+
margin-bottom: 10px;
12+
}
13+
14+
&__chart {
15+
position: relative;
16+
17+
display: flex;
18+
overflow: hidden;
19+
20+
width: 100%;
21+
height: 100%;
22+
}
23+
24+
&__error {
25+
position: absolute;
26+
z-index: 1;
27+
top: 10%;
28+
left: 50%;
29+
30+
text-align: center;
31+
32+
transform: translateX(-50%);
33+
}
34+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import {useCallback, useEffect, useReducer, useRef} from 'react';
2+
3+
import {RawSerieData, YagrPlugin, YagrWidgetData} from '@gravity-ui/chartkit/yagr';
4+
import ChartKit, {settings} from '@gravity-ui/chartkit';
5+
6+
import type {IResponseError} from '../../types/api/error';
7+
import type {TimeFrame} from '../../utils/timeframes';
8+
import {useAutofetcher} from '../../utils/hooks';
9+
import {COLORS} from '../../utils/versions';
10+
import {cn} from '../../utils/cn';
11+
12+
import {Loader} from '../Loader';
13+
import {ResponseError} from '../Errors/ResponseError';
14+
15+
import type {ChartOptions, MetricDescription, PreparedMetricsData} from './types';
16+
import {convertResponse} from './convertReponse';
17+
import {getDefaultDataFormatter} from './getDefaultDataFormatter';
18+
import {getChartData} from './getChartData';
19+
import {
20+
chartReducer,
21+
initialChartState,
22+
setChartData,
23+
setChartDataLoading,
24+
setChartDataWasNotLoaded,
25+
setChartError,
26+
} from './reducer';
27+
28+
import './MetricChart.scss';
29+
30+
const b = cn('ydb-metric-chart');
31+
32+
settings.set({plugins: [YagrPlugin]});
33+
34+
const prepareWidgetData = (
35+
data: PreparedMetricsData,
36+
options: ChartOptions = {},
37+
): YagrWidgetData => {
38+
const {dataType} = options;
39+
const defaultDataFormatter = getDefaultDataFormatter(dataType);
40+
41+
const isDataEmpty = !data.metrics.length;
42+
43+
const graphs: RawSerieData[] = data.metrics.map((metric, index) => {
44+
return {
45+
id: metric.target,
46+
name: metric.title || metric.target,
47+
color: metric.color || COLORS[index],
48+
data: metric.data,
49+
formatter: defaultDataFormatter,
50+
};
51+
});
52+
53+
return {
54+
data: {
55+
timeline: data.timeline,
56+
graphs,
57+
},
58+
59+
libraryConfig: {
60+
chart: {
61+
size: {
62+
// When empty data chart is displayed without axes it have different paddings
63+
// To compensate it, additional paddings are applied
64+
padding: isDataEmpty ? [10, 0, 10, 0] : undefined,
65+
},
66+
series: {
67+
type: 'line',
68+
},
69+
select: {
70+
zoom: false,
71+
},
72+
},
73+
scales: {
74+
y: {
75+
type: 'linear',
76+
range: 'nice',
77+
},
78+
},
79+
axes: {
80+
y: {
81+
values: defaultDataFormatter
82+
? (_, ticks) => ticks.map(defaultDataFormatter)
83+
: undefined,
84+
},
85+
},
86+
tooltip: {
87+
show: true,
88+
tracking: 'sticky',
89+
},
90+
},
91+
};
92+
};
93+
94+
interface DiagnosticsChartProps {
95+
title?: string;
96+
metrics: MetricDescription[];
97+
timeFrame?: TimeFrame;
98+
99+
autorefresh?: boolean;
100+
101+
height?: number;
102+
width?: number;
103+
104+
chartOptions?: ChartOptions;
105+
}
106+
107+
export const MetricChart = ({
108+
title,
109+
metrics,
110+
timeFrame = '1h',
111+
autorefresh,
112+
width = 400,
113+
height = width / 1.5,
114+
chartOptions,
115+
}: DiagnosticsChartProps) => {
116+
const mounted = useRef(false);
117+
118+
useEffect(() => {
119+
mounted.current = true;
120+
return () => {
121+
mounted.current = false;
122+
};
123+
}, []);
124+
125+
const [{loading, wasLoaded, data, error}, dispatch] = useReducer(
126+
chartReducer,
127+
initialChartState,
128+
);
129+
130+
const fetchChartData = useCallback(
131+
async (isBackground: boolean) => {
132+
dispatch(setChartDataLoading());
133+
134+
if (!isBackground) {
135+
dispatch(setChartDataWasNotLoaded());
136+
}
137+
138+
try {
139+
// maxDataPoints param is calculated based on width
140+
// should be width > maxDataPoints to prevent points that cannot be selected
141+
// more px per dataPoint - easier to select, less - chart is smoother
142+
const response = await getChartData({
143+
metrics,
144+
timeFrame,
145+
maxDataPoints: width / 2,
146+
});
147+
148+
// Hack to prevent setting value to state, if component unmounted
149+
if (!mounted.current) return;
150+
151+
// In some cases error could be in response with 200 status code
152+
// It happens when request is OK, but chart data cannot be returned due to some reason
153+
// Example: charts are not enabled in the DB ('GraphShard is not enabled' error)
154+
if (Array.isArray(response)) {
155+
const preparedData = convertResponse(response, metrics);
156+
dispatch(setChartData(preparedData));
157+
} else {
158+
dispatch(setChartError({statusText: response.error}));
159+
}
160+
} catch (err) {
161+
if (!mounted.current) return;
162+
163+
dispatch(setChartError(err as IResponseError));
164+
}
165+
},
166+
[metrics, timeFrame, width],
167+
);
168+
169+
useAutofetcher(fetchChartData, [fetchChartData], autorefresh);
170+
171+
const convertedData = prepareWidgetData(data, chartOptions);
172+
173+
const renderContent = () => {
174+
if (loading && !wasLoaded) {
175+
return <Loader />;
176+
}
177+
178+
return (
179+
<div className={b('chart')}>
180+
<ChartKit type="yagr" data={convertedData} />
181+
{error && <ResponseError className={b('error')} error={error} />}
182+
</div>
183+
);
184+
};
185+
186+
return (
187+
<div
188+
className={b(null)}
189+
style={{
190+
height,
191+
width,
192+
}}
193+
>
194+
<div className={b('title')}>{title}</div>
195+
{renderContent()}
196+
</div>
197+
);
198+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type {MetricData} from '../../types/api/render';
2+
import type {MetricDescription, PreparedMetric, PreparedMetricsData} from './types';
3+
4+
export const convertResponse = (
5+
data: MetricData[] = [],
6+
metrics: MetricDescription[],
7+
): PreparedMetricsData => {
8+
const preparedMetrics = data
9+
.map(({datapoints, target}) => {
10+
const metricDescription = metrics.find((metric) => metric.target === target);
11+
const chartData = datapoints.map((datapoint) => datapoint[0] || 0);
12+
13+
if (!metricDescription) {
14+
return undefined;
15+
}
16+
17+
return {
18+
...metricDescription,
19+
data: chartData,
20+
};
21+
})
22+
.filter((metric): metric is PreparedMetric => metric !== undefined);
23+
24+
// Asuming all metrics in response have the same timeline
25+
// Backend return data in seconds, while chart needs ms
26+
const timeline = data[0].datapoints.map((datapoint) => datapoint[1] * 1000);
27+
28+
return {
29+
timeline,
30+
metrics: preparedMetrics,
31+
};
32+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {TIMEFRAMES, type TimeFrame} from '../../utils/timeframes';
2+
import type {MetricDescription} from './types';
3+
4+
interface GetChartDataParams {
5+
metrics: MetricDescription[];
6+
timeFrame: TimeFrame;
7+
maxDataPoints: number;
8+
}
9+
10+
export const getChartData = async ({metrics, timeFrame, maxDataPoints}: GetChartDataParams) => {
11+
const targetString = metrics.map((metric) => `target=${metric.target}`).join('&');
12+
13+
const until = Math.round(Date.now() / 1000);
14+
const from = until - TIMEFRAMES[timeFrame];
15+
16+
return window.api.getChartData(
17+
{target: targetString, from, until, maxDataPoints},
18+
{concurrentId: `getChartData|${targetString}`},
19+
);
20+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {formatBytes} from '../../utils/bytesParsers';
2+
import {roundToPrecision} from '../../utils/dataFormatters/dataFormatters';
3+
import {formatToMs} from '../../utils/timeParsers';
4+
import {isNumeric} from '../../utils/utils';
5+
6+
import type {ChartDataType, ChartValue} from './types';
7+
8+
export const getDefaultDataFormatter = (dataType?: ChartDataType) => {
9+
switch (dataType) {
10+
case 'ms': {
11+
return formatChartValueToMs;
12+
}
13+
case 'size': {
14+
return formatChartValueToSize;
15+
}
16+
default:
17+
return undefined;
18+
}
19+
};
20+
21+
function formatChartValueToMs(value: ChartValue) {
22+
return formatToMs(roundToPrecision(convertToNumber(value), 2));
23+
}
24+
25+
function formatChartValueToSize(value: ChartValue) {
26+
return formatBytes({value: convertToNumber(value), precision: 3});
27+
}
28+
29+
// Numeric values expected, not numeric value should be displayd as 0
30+
function convertToNumber(value: unknown): number {
31+
if (isNumeric(value)) {
32+
return Number(value);
33+
}
34+
35+
return 0;
36+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type {MetricDescription, Metric, ChartOptions} from './types';
2+
export {MetricChart} from './MetricChart';

0 commit comments

Comments
 (0)