ydb-embedded-ui 3.0.1 → 3.1.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.
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
  ],