ydb-embedded-ui 4.29.0 → 4.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +2 -1
  2. package/dist/components/ClipboardButton/ClipboardButton.tsx +52 -0
  3. package/dist/components/ClipboardButton/index.ts +1 -0
  4. package/dist/components/EntityStatus/EntityStatus.js +9 -12
  5. package/dist/components/EntityStatus/EntityStatus.scss +2 -13
  6. package/dist/components/MetricChart/MetricChart.scss +34 -0
  7. package/dist/components/MetricChart/MetricChart.tsx +198 -0
  8. package/dist/components/MetricChart/convertReponse.ts +32 -0
  9. package/dist/components/MetricChart/getChartData.ts +20 -0
  10. package/dist/components/MetricChart/getDefaultDataFormatter.ts +36 -0
  11. package/dist/components/MetricChart/index.ts +2 -0
  12. package/dist/components/MetricChart/reducer.ts +86 -0
  13. package/dist/components/MetricChart/types.ts +32 -0
  14. package/dist/components/TimeFrameSelector/TimeFrameSelector.scss +5 -0
  15. package/dist/components/TimeFrameSelector/TimeFrameSelector.tsx +33 -0
  16. package/dist/containers/App/App.scss +9 -9
  17. package/dist/containers/App/Content.js +16 -12
  18. package/dist/containers/Tenant/Diagnostics/TenantOverview/DefaultDashboard.tsx +50 -0
  19. package/dist/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricsCards.tsx +13 -4
  20. package/dist/containers/Tenant/Diagnostics/TenantOverview/TenantCpu/CpuDashboard.tsx +18 -0
  21. package/dist/containers/Tenant/Diagnostics/TenantOverview/TenantCpu/TenantCpu.tsx +2 -0
  22. package/dist/containers/Tenant/Diagnostics/TenantOverview/TenantDashboard/TenantDashboard.scss +14 -0
  23. package/dist/containers/Tenant/Diagnostics/TenantOverview/TenantDashboard/TenantDashboard.tsx +71 -0
  24. package/dist/containers/Tenant/Diagnostics/TenantOverview/TenantMemory/MemoryDashboard.tsx +21 -0
  25. package/dist/containers/Tenant/Diagnostics/TenantOverview/TenantMemory/TenantMemory.tsx +7 -1
  26. package/dist/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx +2 -1
  27. package/dist/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/StorageDashboard.tsx +21 -0
  28. package/dist/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorage.tsx +2 -0
  29. package/dist/containers/Tenant/Diagnostics/TenantOverview/i18n/en.json +7 -1
  30. package/dist/containers/Tenant/Diagnostics/TenantOverview/i18n/ru.json +7 -1
  31. package/dist/containers/Tenant/ObjectSummary/ObjectSummary.tsx +24 -30
  32. package/dist/containers/Tenant/Query/ExecuteResult/ExecuteResult.tsx +10 -16
  33. package/dist/containers/UserSettings/i18n/en.json +4 -1
  34. package/dist/containers/UserSettings/i18n/ru.json +4 -1
  35. package/dist/containers/UserSettings/settings.ts +12 -1
  36. package/dist/containers/Versions/NodesTreeTitle/NodesTreeTitle.scss +15 -0
  37. package/dist/containers/Versions/NodesTreeTitle/NodesTreeTitle.tsx +9 -6
  38. package/dist/services/api.ts +18 -0
  39. package/dist/services/settings.ts +2 -0
  40. package/dist/store/reducers/tenant/tenant.ts +6 -1
  41. package/dist/types/api/render.ts +34 -0
  42. package/dist/utils/cn.ts +3 -0
  43. package/dist/utils/constants.ts +2 -0
  44. package/dist/utils/timeParsers/formatDuration.ts +10 -0
  45. package/dist/utils/timeframes.ts +10 -0
  46. package/dist/utils/versions/getVersionsColors.ts +1 -0
  47. package/package.json +4 -2
  48. package/CHANGELOG.md +0 -1552
  49. package/dist/components/CopyToClipboard/CopyToClipboard.tsx +0 -38
package/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  Local viewer for YDB clusters
4
4
 
5
- [Docs for users](https://ydb.tech/en/docs/maintenance/embedded_monitoring/ydb_monitoring)
5
+ * [Docs for users](https://ydb.tech/en/docs/maintenance/embedded_monitoring/ydb_monitoring)
6
+ * [Project Roadmap](ROADMAP.md)
6
7
 
7
8
  ## Preview
8
9
 
@@ -0,0 +1,52 @@
1
+ import {
2
+ Button,
3
+ ButtonProps,
4
+ ClipboardIcon,
5
+ CopyToClipboard as CopyToClipboardUiKit,
6
+ CopyToClipboardStatus,
7
+ Tooltip,
8
+ } from '@gravity-ui/uikit';
9
+ import cn from 'bem-cn-lite';
10
+
11
+ const b = cn('clipboard-button');
12
+
13
+ interface ClipboardButtonProps extends Pick<ButtonProps, 'disabled' | 'size' | 'title' | 'view'> {
14
+ className?: string;
15
+ text: string;
16
+ }
17
+
18
+ /**
19
+ * An inner component required
20
+ * because `react-copy-to-clipboard` doesn't work with `Tooltip` otherwise.
21
+ */
22
+ function InnerButton({
23
+ className,
24
+ status,
25
+ title,
26
+ ...props
27
+ }: Omit<ClipboardButtonProps, 'text'> & {status: CopyToClipboardStatus}) {
28
+ return (
29
+ <Tooltip
30
+ content={status === CopyToClipboardStatus.Success ? 'Copied!' : title || 'Copy'}
31
+ /**
32
+ * Auto-placement has a bug with text changing.
33
+ * @link https://github.com/ydb-platform/ydb-embedded-ui/pull/648#discussion_r1453530092
34
+ */
35
+ placement="bottom-start"
36
+ >
37
+ <Button {...props} className={b(null, className)}>
38
+ <Button.Icon>
39
+ <ClipboardIcon status={status} size={16} />
40
+ </Button.Icon>
41
+ </Button>
42
+ </Tooltip>
43
+ );
44
+ }
45
+
46
+ export function ClipboardButton({text, ...props}: ClipboardButtonProps) {
47
+ return (
48
+ <CopyToClipboardUiKit text={text} timeout={1000}>
49
+ {(status) => <InnerButton {...props} status={status} />}
50
+ </CopyToClipboardUiKit>
51
+ );
52
+ }
@@ -0,0 +1 @@
1
+ export * from './ClipboardButton';
@@ -1,14 +1,13 @@
1
- import React from 'react';
2
- import PropTypes from 'prop-types';
1
+ import {Icon, Link as UIKitLink} from '@gravity-ui/uikit';
3
2
  import cn from 'bem-cn-lite';
3
+ import PropTypes from 'prop-types';
4
+ import React from 'react';
4
5
  import {Link} from 'react-router-dom';
5
- import {ClipboardButton, Link as UIKitLink, Button, Icon} from '@gravity-ui/uikit';
6
-
7
- import circleInfoIcon from '../../assets/icons/circle-info.svg';
8
6
  import circleExclamationIcon from '../../assets/icons/circle-exclamation.svg';
9
- import triangleExclamationIcon from '../../assets/icons/triangle-exclamation.svg';
7
+ import circleInfoIcon from '../../assets/icons/circle-info.svg';
10
8
  import circleTimesIcon from '../../assets/icons/circle-xmark.svg';
11
-
9
+ import triangleExclamationIcon from '../../assets/icons/triangle-exclamation.svg';
10
+ import {ClipboardButton} from '../ClipboardButton';
12
11
  import './EntityStatus.scss';
13
12
 
14
13
  const icons = {
@@ -121,15 +120,13 @@ class EntityStatus extends React.Component {
121
120
  {this.renderLink()}
122
121
  </span>
123
122
  {hasClipboardButton && (
124
- <Button
125
- component="span"
123
+ <ClipboardButton
124
+ text={name}
126
125
  size="s"
127
126
  className={b('clipboard-button', {
128
127
  visible: this.props.clipboardButtonAlwaysVisible,
129
128
  })}
130
- >
131
- <ClipboardButton text={name} size={16} />
132
- </Button>
129
+ />
133
130
  )}
134
131
  </div>
135
132
  );
@@ -10,25 +10,14 @@
10
10
  @include body-2-typography();
11
11
 
12
12
  &__clipboard-button {
13
- display: none;
14
- justify-content: center;
15
- align-items: center;
13
+ visibility: hidden;
16
14
 
17
15
  margin-left: 8px;
18
16
 
19
17
  color: var(--yc-color-text-secondary);
20
18
 
21
- .yc-button__text {
22
- margin: 0;
23
- }
24
-
25
- .yc-clipboard-button {
26
- width: 24px;
27
- height: 24px;
28
- }
29
-
30
19
  &_visible {
31
- display: inline-flex;
20
+ visibility: visible;
32
21
  }
33
22
  }
34
23
 
@@ -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
+ }
@@ -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
+ };
@@ -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
+ };
@@ -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
+ };
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export type {MetricDescription, Metric, ChartOptions} from './types';
2
+ export {MetricChart} from './MetricChart';
@@ -0,0 +1,86 @@
1
+ import {createRequestActionTypes} from '../../store/utils';
2
+ import type {IResponseError} from '../../types/api/error';
3
+
4
+ import type {PreparedMetricsData} from './types';
5
+
6
+ const FETCH_CHART_DATA = createRequestActionTypes('chart', 'FETCH_CHART_DATA');
7
+ const SET_CHART_DATA_WAS_NOT_LOADED = 'chart/SET_DATA_WAS_NOT_LOADED';
8
+
9
+ export const setChartDataLoading = () => {
10
+ return {
11
+ type: FETCH_CHART_DATA.REQUEST,
12
+ } as const;
13
+ };
14
+
15
+ export const setChartData = (data: PreparedMetricsData) => {
16
+ return {
17
+ data,
18
+ type: FETCH_CHART_DATA.SUCCESS,
19
+ } as const;
20
+ };
21
+
22
+ export const setChartError = (error: IResponseError) => {
23
+ return {
24
+ error,
25
+ type: FETCH_CHART_DATA.FAILURE,
26
+ } as const;
27
+ };
28
+
29
+ export const setChartDataWasNotLoaded = () => {
30
+ return {
31
+ type: SET_CHART_DATA_WAS_NOT_LOADED,
32
+ } as const;
33
+ };
34
+
35
+ type ChartAction =
36
+ | ReturnType<typeof setChartDataLoading>
37
+ | ReturnType<typeof setChartData>
38
+ | ReturnType<typeof setChartError>
39
+ | ReturnType<typeof setChartDataWasNotLoaded>;
40
+
41
+ interface ChartState {
42
+ loading: boolean;
43
+ wasLoaded: boolean;
44
+ data: PreparedMetricsData;
45
+ error: IResponseError | undefined;
46
+ }
47
+
48
+ export const initialChartState: ChartState = {
49
+ // Set chart initial state as loading, in order not to mount and unmount component in between requests
50
+ // as it leads to memory leak errors in console (not proper useEffect cleanups in chart component itself)
51
+ // TODO: possible fix (check needed): chart component is always present, but display: none for chart while loading
52
+ loading: true,
53
+ wasLoaded: false,
54
+ data: {timeline: [], metrics: []},
55
+ error: undefined,
56
+ };
57
+
58
+ export const chartReducer = (state: ChartState, action: ChartAction) => {
59
+ switch (action.type) {
60
+ case FETCH_CHART_DATA.REQUEST: {
61
+ return {...state, loading: true};
62
+ }
63
+ case FETCH_CHART_DATA.SUCCESS: {
64
+ return {...state, loading: false, wasLoaded: true, error: undefined, data: action.data};
65
+ }
66
+ case FETCH_CHART_DATA.FAILURE: {
67
+ if (action.error?.isCancelled) {
68
+ return state;
69
+ }
70
+
71
+ return {
72
+ ...state,
73
+ error: action.error,
74
+ // Clear data, so error will be displayed with empty chart
75
+ data: {timeline: [], metrics: []},
76
+ loading: false,
77
+ wasLoaded: true,
78
+ };
79
+ }
80
+ case SET_CHART_DATA_WAS_NOT_LOADED: {
81
+ return {...state, wasLoaded: false};
82
+ }
83
+ default:
84
+ return state;
85
+ }
86
+ };
@@ -0,0 +1,32 @@
1
+ export type Metric =
2
+ | 'queries.requests'
3
+ | 'queries.latencies.p50'
4
+ | 'queries.latencies.p75'
5
+ | 'queries.latencies.p90'
6
+ | 'queries.latencies.p99'
7
+ | 'resources.cpu.usage'
8
+ | 'resources.memory.used_bytes'
9
+ | 'resources.storage.used_bytes';
10
+
11
+ export interface MetricDescription {
12
+ target: Metric;
13
+ title?: string;
14
+ color?: string;
15
+ }
16
+
17
+ export interface PreparedMetric extends MetricDescription {
18
+ data: number[];
19
+ }
20
+
21
+ export interface PreparedMetricsData {
22
+ timeline: number[];
23
+ metrics: PreparedMetric[];
24
+ }
25
+
26
+ export type ChartValue = number | string | null;
27
+
28
+ export type ChartDataType = 'ms' | 'size';
29
+
30
+ export interface ChartOptions {
31
+ dataType?: ChartDataType;
32
+ }
@@ -0,0 +1,5 @@
1
+ .ydb-timeframe-selector {
2
+ display: flex;
3
+
4
+ gap: 2px;
5
+ }
@@ -0,0 +1,33 @@
1
+ import {Button} from '@gravity-ui/uikit';
2
+
3
+ import {cn} from '../../utils/cn';
4
+ import {TIMEFRAMES, type TimeFrame} from '../../utils/timeframes';
5
+
6
+ import './TimeFrameSelector.scss';
7
+
8
+ const b = cn('ydb-timeframe-selector');
9
+
10
+ interface TimeFrameSelectorProps {
11
+ value: TimeFrame;
12
+ onChange: (value: TimeFrame) => void;
13
+ className?: string;
14
+ }
15
+
16
+ export const TimeFrameSelector = ({value, onChange, className}: TimeFrameSelectorProps) => {
17
+ return (
18
+ <div className={b(null, className)}>
19
+ {Object.keys(TIMEFRAMES).map((timeFrame) => {
20
+ return (
21
+ <Button
22
+ view="flat"
23
+ selected={value === timeFrame}
24
+ key={timeFrame}
25
+ onClick={() => onChange(timeFrame as TimeFrame)}
26
+ >
27
+ {timeFrame}
28
+ </Button>
29
+ );
30
+ })}
31
+ </div>
32
+ );
33
+ };
@@ -107,21 +107,21 @@ body,
107
107
  border-right: unset;
108
108
  border-left: unset;
109
109
  }
110
-
111
- .yc-clipboard-button {
112
- display: inline-flex;
113
- justify-content: center;
114
- align-items: center;
115
- }
116
110
  }
117
111
 
118
112
  .error {
119
113
  color: var(--g-color-text-danger);
120
114
  }
121
115
 
122
- .data-table__row:hover .entity-status__clipboard-button,
123
- .ydb-virtual-table__row:hover .entity-status__clipboard-button {
124
- display: flex;
116
+ .data-table__row,
117
+ .ydb-virtual-table__row,
118
+ .ydb-tree-view__item {
119
+ &:hover,
120
+ &:focus-within {
121
+ & .clipboard-button {
122
+ visibility: visible;
123
+ }
124
+ }
125
125
  }
126
126
 
127
127
  .g-root .data-table_highlight-rows .data-table__row:hover {