ydb-embedded-ui 3.0.1 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.1.0](https://github.com/ydb-platform/ydb-embedded-ui/compare/v3.0.1...v3.1.0) (2022-12-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * **TopShards:** date range filter ([aab4396](https://github.com/ydb-platform/ydb-embedded-ui/commit/aab439600ec28d30799c4a7ef7a9c68fcacc148c))
9
+
3
10
  ## [3.0.1](https://github.com/ydb-platform/ydb-embedded-ui/compare/v3.0.0...v3.0.1) (2022-12-12)
4
11
 
5
12
 
@@ -0,0 +1,13 @@
1
+ .top-shards {
2
+ &__date-range {
3
+ &-input {
4
+ min-width: 190px;
5
+ padding: 5px 8px;
6
+
7
+ color: var(--yc-color-text-primary);
8
+ border: 1px solid var(--yc-color-line-generic);
9
+ border-radius: var(--yc-border-radius-m);
10
+ background: transparent;
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,75 @@
1
+ import cn from 'bem-cn-lite';
2
+ import {ChangeEventHandler} from 'react';
3
+
4
+ import './DateRange.scss';
5
+
6
+ const b = cn('top-shards');
7
+
8
+ export interface DateRangeValues {
9
+ /** ms from epoch */
10
+ from?: number;
11
+ /** ms from epoch */
12
+ to?: number;
13
+ }
14
+
15
+ interface DateRangeProps extends DateRangeValues {
16
+ className?: string;
17
+ onChange?: (value: DateRangeValues) => void;
18
+ }
19
+
20
+ const toTimezonelessISOString = (timestamp?: number) => {
21
+ if (!timestamp || isNaN(timestamp)) {
22
+ return undefined;
23
+ }
24
+
25
+ // shift by local offset to treat toISOString output as local time
26
+ const shiftedTimestamp = timestamp - new Date().getTimezoneOffset() * 60 * 1000;
27
+ return new Date(shiftedTimestamp).toISOString().substring(0, 'yyyy-MM-DDThh:mm'.length);
28
+ };
29
+
30
+ export const DateRange = ({from, to, className, onChange}: DateRangeProps) => {
31
+ const handleFromChange: ChangeEventHandler<HTMLInputElement> = ({target: {value}}) => {
32
+ let newFrom = value ? new Date(value).getTime() : undefined;
33
+
34
+ // some browsers allow selecting time after the boundary specified in `max`
35
+ if (newFrom && to && newFrom > to) {
36
+ newFrom = to;
37
+ }
38
+
39
+ onChange?.({from: newFrom, to});
40
+ };
41
+
42
+ const handleToChange: ChangeEventHandler<HTMLInputElement> = ({target: {value}}) => {
43
+ let newTo = value ? new Date(value).getTime() : undefined;
44
+
45
+ // some browsers allow selecting time before the boundary specified in `min`
46
+ if (from && newTo && from > newTo) {
47
+ newTo = from;
48
+ }
49
+
50
+ onChange?.({from, to: newTo});
51
+ };
52
+
53
+ const startISO = toTimezonelessISOString(from);
54
+ const endISO = toTimezonelessISOString(to);
55
+
56
+ return (
57
+ <div className={b('date-range', className)}>
58
+ <input
59
+ type="datetime-local"
60
+ value={startISO}
61
+ max={endISO}
62
+ onChange={handleFromChange}
63
+ className={b('date-range-input')}
64
+ />
65
+
66
+ <input
67
+ type="datetime-local"
68
+ min={startISO}
69
+ value={endISO}
70
+ onChange={handleToChange}
71
+ className={b('date-range-input')}
72
+ />
73
+ </div>
74
+ );
75
+ };
@@ -0,0 +1 @@
1
+ export * from './DateRange';
@@ -1,7 +1,27 @@
1
1
  .top-shards {
2
+ display: flex;
3
+ flex-direction: column;
4
+
5
+ height: 100%;
6
+
2
7
  background-color: var(--yc-color-base-background);
8
+
3
9
  &__loader {
4
10
  display: flex;
5
11
  justify-content: center;
6
12
  }
13
+
14
+ &__controls {
15
+ display: flex;
16
+ flex-wrap: wrap;
17
+ align-items: baseline;
18
+ gap: 16px;
19
+
20
+ margin-bottom: 10px;
21
+ }
22
+
23
+ &__table {
24
+ overflow: auto;
25
+ flex-grow: 1;
26
+ }
7
27
  }
@@ -11,18 +11,28 @@ import HistoryContext from '../../../../contexts/HistoryContext';
11
11
 
12
12
  import routes, {createHref} from '../../../../routes';
13
13
 
14
- import {sendShardQuery, setShardQueryOptions} from '../../../../store/reducers/shardsWorkload';
14
+ import {
15
+ sendShardQuery,
16
+ setShardQueryOptions,
17
+ setTopShardFilters,
18
+ } from '../../../../store/reducers/shardsWorkload';
15
19
  import {setCurrentSchemaPath, getSchema} from '../../../../store/reducers/schema';
20
+ import type {IShardsWorkloadFilters} from '../../../../types/store/shardsWorkload';
16
21
 
17
22
  import type {EPathType} from '../../../../types/api/schema';
18
23
 
19
- import {DEFAULT_TABLE_SETTINGS} from '../../../../utils/constants';
24
+ import {formatDateTime, formatNumber} from '../../../../utils';
25
+ import {DEFAULT_TABLE_SETTINGS, HOUR_IN_SECONDS} from '../../../../utils/constants';
20
26
  import {useAutofetcher, useTypedSelector} from '../../../../utils/hooks';
21
- import {i18n} from '../../../../utils/i18n';
22
27
  import {prepareQueryError} from '../../../../utils/query';
23
28
 
29
+ import {getDefaultNodePath} from '../../../Node/NodePages';
30
+
24
31
  import {isColumnEntityType} from '../../utils/schema';
25
32
 
33
+ import {DateRange, DateRangeValues} from './DateRange';
34
+
35
+ import i18n from './i18n';
26
36
  import './TopShards.scss';
27
37
 
28
38
  const b = cn('top-shards');
@@ -41,16 +51,15 @@ const tableColumnsNames = {
41
51
  CPUCores: 'CPUCores',
42
52
  DataSize: 'DataSize',
43
53
  Path: 'Path',
54
+ NodeId: 'NodeId',
55
+ PeakTime: 'PeakTime',
56
+ InFlightTxCount: 'InFlightTxCount',
44
57
  };
45
58
 
46
59
  function prepareCPUWorkloadValue(value: string) {
47
60
  return `${(Number(value) * 100).toFixed(2)}%`;
48
61
  }
49
62
 
50
- function prepareDateSizeValue(value: number) {
51
- return new Intl.NumberFormat(i18n.lang).format(value);
52
- }
53
-
54
63
  function stringToDataTableSortOrder(value: string): SortOrder[] | undefined {
55
64
  return value
56
65
  ? value.split(',').map((columnId) => ({
@@ -87,10 +96,24 @@ export const TopShards = ({tenantPath, type}: TopShardsProps) => {
87
96
  const {
88
97
  loading,
89
98
  data: {result: data = undefined} = {},
99
+ filters: storeFilters,
90
100
  error,
91
101
  wasLoaded,
92
102
  } = useTypedSelector((state) => state.shardsWorkload);
93
103
 
104
+ // default date range should be the last hour, but shouldn't propagate into URL until user interacts with the control
105
+ // redux initial value can't be used, as it synchronizes with URL
106
+ const [filters, setFilters] = useState<IShardsWorkloadFilters>(() => {
107
+ if (!storeFilters?.from && !storeFilters?.to) {
108
+ return {
109
+ from: Date.now() - HOUR_IN_SECONDS * 1000,
110
+ to: Date.now(),
111
+ };
112
+ }
113
+
114
+ return storeFilters;
115
+ });
116
+
94
117
  const [sortOrder, setSortOrder] = useState(tableColumnsNames.CPUCores);
95
118
 
96
119
  useAutofetcher(
@@ -100,10 +123,11 @@ export const TopShards = ({tenantPath, type}: TopShardsProps) => {
100
123
  database: tenantPath,
101
124
  path: currentSchemaPath,
102
125
  sortOrder: stringToQuerySortOrder(sortOrder),
126
+ filters,
103
127
  }),
104
128
  );
105
129
  },
106
- [dispatch, currentSchemaPath, tenantPath, sortOrder],
130
+ [dispatch, tenantPath, currentSchemaPath, sortOrder, filters],
107
131
  autorefresh,
108
132
  );
109
133
 
@@ -115,7 +139,7 @@ export const TopShards = ({tenantPath, type}: TopShardsProps) => {
115
139
  data: undefined,
116
140
  }),
117
141
  );
118
- }, [dispatch, currentSchemaPath, tenantPath]);
142
+ }, [dispatch, currentSchemaPath, tenantPath, filters]);
119
143
 
120
144
  const history = useContext(HistoryContext);
121
145
 
@@ -126,6 +150,11 @@ export const TopShards = ({tenantPath, type}: TopShardsProps) => {
126
150
  setSortOrder(dataTableToStringSortOrder(newSortOrder));
127
151
  };
128
152
 
153
+ const handleDateRangeChange = (value: DateRangeValues) => {
154
+ dispatch(setTopShardFilters(value));
155
+ setFilters(value);
156
+ };
157
+
129
158
  const tableColumns: Column<any>[] = useMemo(() => {
130
159
  const onSchemaClick = (schemaPath: string) => {
131
160
  return () => {
@@ -161,7 +190,7 @@ export const TopShards = ({tenantPath, type}: TopShardsProps) => {
161
190
  name: tableColumnsNames.DataSize,
162
191
  header: 'DataSize (B)',
163
192
  render: ({value}) => {
164
- return prepareDateSizeValue(value as number);
193
+ return formatNumber(value as number);
165
194
  },
166
195
  align: DataTable.RIGHT,
167
196
  },
@@ -176,6 +205,29 @@ export const TopShards = ({tenantPath, type}: TopShardsProps) => {
176
205
  },
177
206
  sortable: false,
178
207
  },
208
+ {
209
+ name: tableColumnsNames.NodeId,
210
+ render: ({value: nodeId}) => {
211
+ return (
212
+ <InternalLink to={getDefaultNodePath(nodeId as string)}>
213
+ {nodeId as string}
214
+ </InternalLink>
215
+ );
216
+ },
217
+ align: DataTable.RIGHT,
218
+ sortable: false,
219
+ },
220
+ {
221
+ name: tableColumnsNames.PeakTime,
222
+ render: ({value}) => formatDateTime(new Date(value as string).valueOf()),
223
+ sortable: false,
224
+ },
225
+ {
226
+ name: tableColumnsNames.InFlightTxCount,
227
+ render: ({value}) => formatNumber(value as number),
228
+ align: DataTable.RIGHT,
229
+ sortable: false,
230
+ },
179
231
  ];
180
232
  }, [dispatch, history, tenantPath]);
181
233
 
@@ -192,12 +244,12 @@ export const TopShards = ({tenantPath, type}: TopShardsProps) => {
192
244
  return renderLoader();
193
245
  }
194
246
 
195
- if (!data || data.length === 0 || isColumnEntityType(type)) {
196
- return 'No data';
247
+ if (error && !error.isCancelled) {
248
+ return <div className="error">{prepareQueryError(error)}</div>;
197
249
  }
198
250
 
199
- if (error && !error.isCancelled) {
200
- return prepareQueryError(error);
251
+ if (!data || isColumnEntityType(type)) {
252
+ return i18n('no-data');
201
253
  }
202
254
 
203
255
  return (
@@ -216,6 +268,10 @@ export const TopShards = ({tenantPath, type}: TopShardsProps) => {
216
268
 
217
269
  return (
218
270
  <div className={b()}>
271
+ <div className={b('controls')}>
272
+ {i18n('description')}
273
+ <DateRange from={filters.from} to={filters.to} onChange={handleDateRangeChange} />
274
+ </div>
219
275
  {renderContent()}
220
276
  </div>
221
277
  );
@@ -0,0 +1,4 @@
1
+ {
2
+ "no-data": "No data",
3
+ "description": "Shards with CPU load over 70% are listed"
4
+ }
@@ -0,0 +1,11 @@
1
+ import {i18n, Lang} from '../../../../../utils/i18n';
2
+
3
+ import en from './en.json';
4
+ import ru from './ru.json';
5
+
6
+ const COMPONENT = 'ydb-diagnostics-top-shards';
7
+
8
+ i18n.registerKeyset(Lang.En, COMPONENT, en);
9
+ i18n.registerKeyset(Lang.Ru, COMPONENT, ru);
10
+
11
+ export default i18n.keyset(COMPONENT);
@@ -0,0 +1,4 @@
1
+ {
2
+ "no-data": "Нет данных",
3
+ "description": "Отображаются шарды с загрузкой CPU выше 70%"
4
+ }
@@ -1,7 +1,11 @@
1
1
  import type {Reducer} from 'redux';
2
2
 
3
3
  import '../../services/api';
4
- import type {IShardsWorkloadAction, IShardsWorkloadState} from '../../types/store/shardsWorkload';
4
+ import type {
5
+ IShardsWorkloadAction,
6
+ IShardsWorkloadFilters,
7
+ IShardsWorkloadState,
8
+ } from '../../types/store/shardsWorkload';
5
9
 
6
10
  import {parseQueryAPIExecuteResponse} from '../../utils/query';
7
11
 
@@ -9,10 +13,12 @@ import {createRequestActionTypes, createApiRequest} from '../utils';
9
13
 
10
14
  export const SEND_SHARD_QUERY = createRequestActionTypes('query', 'SEND_SHARD_QUERY');
11
15
  const SET_SHARD_QUERY_OPTIONS = 'query/SET_SHARD_QUERY_OPTIONS';
16
+ const SET_TOP_SHARDS_FILTERS = 'shardsWorkload/SET_TOP_SHARDS_FILTERS';
12
17
 
13
18
  const initialState = {
14
19
  loading: false,
15
20
  wasLoaded: false,
21
+ filters: {},
16
22
  };
17
23
 
18
24
  export interface SortOrder {
@@ -24,22 +30,56 @@ function formatSortOrder({columnId, order}: SortOrder) {
24
30
  return `${columnId} ${order}`;
25
31
  }
26
32
 
27
- function createShardQuery(path: string, sortOrder?: SortOrder[], tenantName?: string) {
28
- const orderBy = sortOrder ? `ORDER BY ${sortOrder.map(formatSortOrder).join(', ')}` : '';
33
+ function getFiltersConditions(filters?: IShardsWorkloadFilters) {
34
+ const conditions: string[] = [];
35
+
36
+ if (filters?.from && filters?.to && filters.from > filters.to) {
37
+ throw new Error('Invalid date range');
38
+ }
29
39
 
40
+ if (filters?.from) {
41
+ // matching `from` & `to` is an edge case
42
+ // other cases should not include the starting point, since intervals are stored using the ending time
43
+ const gt = filters.to === filters.from ? '>=' : '>';
44
+ conditions.push(`IntervalEnd ${gt} Timestamp('${new Date(filters.from).toISOString()}')`);
45
+ }
46
+
47
+ if (filters?.to) {
48
+ conditions.push(`IntervalEnd <= Timestamp('${new Date(filters.to).toISOString()}')`);
49
+ }
50
+
51
+ return conditions.join(' AND ');
52
+ }
53
+
54
+ function createShardQuery(
55
+ path: string,
56
+ filters?: IShardsWorkloadFilters,
57
+ sortOrder?: SortOrder[],
58
+ tenantName?: string,
59
+ ) {
30
60
  const pathSelect = tenantName
31
61
  ? `CAST(SUBSTRING(CAST(Path AS String), ${tenantName.length}) AS Utf8) AS Path`
32
62
  : 'Path';
33
63
 
64
+ let where = `Path='${path}' OR Path LIKE '${path}/%'`;
65
+
66
+ const filterConditions = getFiltersConditions(filters);
67
+ if (filterConditions.length) {
68
+ where = `(${where}) AND ${filterConditions}`;
69
+ }
70
+
71
+ const orderBy = sortOrder ? `ORDER BY ${sortOrder.map(formatSortOrder).join(', ')}` : '';
72
+
34
73
  return `SELECT
35
74
  ${pathSelect},
36
75
  TabletId,
37
76
  CPUCores,
38
- DataSize
39
- FROM \`.sys/partition_stats\`
40
- WHERE
41
- Path='${path}'
42
- OR Path LIKE '${path}/%'
77
+ DataSize,
78
+ NodeId,
79
+ PeakTime,
80
+ InFlightTxCount
81
+ FROM \`.sys/top_partitions_one_hour\`
82
+ WHERE ${where}
43
83
  ${orderBy}
44
84
  LIMIT 20`;
45
85
  }
@@ -80,6 +120,14 @@ const shardsWorkload: Reducer<IShardsWorkloadState, IShardsWorkloadAction> = (
80
120
  ...state,
81
121
  ...action.data,
82
122
  };
123
+ case SET_TOP_SHARDS_FILTERS:
124
+ return {
125
+ ...state,
126
+ filters: {
127
+ ...state.filters,
128
+ ...action.filters,
129
+ },
130
+ };
83
131
  default:
84
132
  return state;
85
133
  }
@@ -89,21 +137,32 @@ interface SendShardQueryParams {
89
137
  database?: string;
90
138
  path?: string;
91
139
  sortOrder?: SortOrder[];
140
+ filters?: IShardsWorkloadFilters;
92
141
  }
93
142
 
94
- export const sendShardQuery = ({database, path = '', sortOrder}: SendShardQueryParams) => {
95
- return createApiRequest({
96
- request: window.api.sendQuery({
97
- schema: 'modern',
98
- query: createShardQuery(path, sortOrder, database),
99
- database,
100
- action: queryAction,
101
- }, {
102
- concurrentId: 'topShards',
103
- }),
104
- actions: SEND_SHARD_QUERY,
105
- dataHandler: parseQueryAPIExecuteResponse,
106
- });
143
+ export const sendShardQuery = ({database, path = '', sortOrder, filters}: SendShardQueryParams) => {
144
+ try {
145
+ return createApiRequest({
146
+ request: window.api.sendQuery(
147
+ {
148
+ schema: 'modern',
149
+ query: createShardQuery(path, filters, sortOrder, database),
150
+ database,
151
+ action: queryAction,
152
+ },
153
+ {
154
+ concurrentId: 'topShards',
155
+ },
156
+ ),
157
+ actions: SEND_SHARD_QUERY,
158
+ dataHandler: parseQueryAPIExecuteResponse,
159
+ });
160
+ } catch (error) {
161
+ return {
162
+ type: SEND_SHARD_QUERY.FAILURE,
163
+ error,
164
+ };
165
+ }
107
166
  };
108
167
 
109
168
  export function setShardQueryOptions(options: Partial<IShardsWorkloadState>) {
@@ -113,4 +172,11 @@ export function setShardQueryOptions(options: Partial<IShardsWorkloadState>) {
113
172
  } as const;
114
173
  }
115
174
 
175
+ export function setTopShardFilters(filters: Partial<IShardsWorkloadFilters>) {
176
+ return {
177
+ type: SET_TOP_SHARDS_FILTERS,
178
+ filters,
179
+ } as const;
180
+ }
181
+
116
182
  export default shardsWorkload;
@@ -48,6 +48,14 @@ const paramSetup = {
48
48
  generalTab: {
49
49
  stateKey: 'tenant.diagnosticsTab',
50
50
  },
51
+ topShardsFrom: {
52
+ stateKey: 'shardsWorkload.filters.from',
53
+ type: 'number',
54
+ },
55
+ topShardsTo: {
56
+ stateKey: 'shardsWorkload.filters.to',
57
+ type: 'number',
58
+ },
51
59
  },
52
60
  };
53
61
 
@@ -1,18 +1,27 @@
1
- import {SEND_SHARD_QUERY, setShardQueryOptions} from '../../store/reducers/shardsWorkload';
1
+ import {SEND_SHARD_QUERY, setShardQueryOptions, setTopShardFilters} from '../../store/reducers/shardsWorkload';
2
2
  import type {ApiRequestAction} from '../../store/utils';
3
3
  import type {IResponseError} from '../api/error';
4
4
  import type {IQueryResult} from './query';
5
5
 
6
+ export interface IShardsWorkloadFilters {
7
+ /** ms from epoch */
8
+ from?: number;
9
+ /** ms from epoch */
10
+ to?: number;
11
+ }
12
+
6
13
  export interface IShardsWorkloadState {
7
14
  loading: boolean;
8
15
  wasLoaded: boolean;
9
16
  data?: IQueryResult;
10
17
  error?: IResponseError;
18
+ filters: IShardsWorkloadFilters;
11
19
  }
12
20
 
13
21
  export type IShardsWorkloadAction =
14
22
  | ApiRequestAction<typeof SEND_SHARD_QUERY, IQueryResult, IResponseError>
15
- | ReturnType<typeof setShardQueryOptions>;
23
+ | ReturnType<typeof setShardQueryOptions>
24
+ | ReturnType<typeof setTopShardFilters>;
16
25
 
17
26
  export interface IShardsWorkloadRootStateSlice {
18
27
  shardsWorkload: IShardsWorkloadState;
@@ -186,5 +186,5 @@ export const prepareQueryResponse = (data?: KeyValueRow[]) => {
186
186
  };
187
187
 
188
188
  export function prepareQueryError(error: any) {
189
- return error.data?.error?.message || error.data || error.statusText || JSON.stringify(error);
189
+ return error.data?.error?.message || error.message || error.data || error.statusText || JSON.stringify(error);
190
190
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ydb-embedded-ui",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "files": [
5
5
  "dist"
6
6
  ],