ydb-embedded-ui 4.29.0 → 4.31.0

Sign up to get free protection for your applications and to get access to all the features.
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 {