Skip to content

Commit 810f60b

Browse files
feat: allow pass create and delete DB funcs to UI (#2087)
1 parent 10e173c commit 810f60b

File tree

14 files changed

+262
-17
lines changed

14 files changed

+262
-17
lines changed

src/containers/App/Content.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
useCapabilitiesLoaded,
1818
useCapabilitiesQuery,
1919
useClusterWithoutAuthInUI,
20+
useMetaCapabilitiesLoaded,
21+
useMetaCapabilitiesQuery,
2022
} from '../../store/reducers/capabilities/hooks';
2123
import {nodesListApi} from '../../store/reducers/nodesList';
2224
import {cn} from '../../utils/cn';
@@ -213,8 +215,12 @@ function GetCapabilities({children}: {children: React.ReactNode}) {
213215
useCapabilitiesQuery();
214216
const capabilitiesLoaded = useCapabilitiesLoaded();
215217

218+
useMetaCapabilitiesQuery();
219+
// It is always true if there is no meta, since request finishes with an error
220+
const metaCapabilitiesLoaded = useMetaCapabilitiesLoaded();
221+
216222
return (
217-
<LoaderWrapper loading={!capabilitiesLoaded} size="l">
223+
<LoaderWrapper loading={!capabilitiesLoaded || !metaCapabilitiesLoaded} size="l">
218224
{children}
219225
</LoaderWrapper>
220226
);

src/containers/Tenants/Tenants.scss

+19
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,23 @@
5252
&__name {
5353
overflow: hidden;
5454
}
55+
56+
&__controls {
57+
width: 100%;
58+
}
59+
60+
&__table-wrapper {
61+
width: max-content;
62+
}
63+
64+
&__create-database {
65+
position: sticky;
66+
right: 0;
67+
68+
margin: 0 0 0 auto;
69+
}
70+
71+
&__remove-db {
72+
color: var(--ydb-color-status-red);
73+
}
5574
}

src/containers/Tenants/Tenants.tsx

+94-9
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import React from 'react';
22

3+
import {CirclePlus, TrashBin} from '@gravity-ui/icons';
34
import type {Column} from '@gravity-ui/react-data-table';
45
import DataTable from '@gravity-ui/react-data-table';
5-
import {Button} from '@gravity-ui/uikit';
6+
import type {DropdownMenuItem} from '@gravity-ui/uikit';
7+
import {Button, DropdownMenu, Icon} from '@gravity-ui/uikit';
68

79
import {EntitiesCount} from '../../components/EntitiesCount';
810
import {ResponseError} from '../../components/Errors/ResponseError';
@@ -13,7 +15,10 @@ import {ResizeableDataTable} from '../../components/ResizeableDataTable/Resizeab
1315
import {Search} from '../../components/Search';
1416
import {TableWithControlsLayout} from '../../components/TableWithControlsLayout/TableWithControlsLayout';
1517
import {TenantNameWrapper} from '../../components/TenantNameWrapper/TenantNameWrapper';
16-
import {clusterName} from '../../store';
18+
import {
19+
useCreateDatabaseFeatureAvailable,
20+
useDeleteDatabaseFeatureAvailable,
21+
} from '../../store/reducers/capabilities/hooks';
1722
import {
1823
ProblemFilterValues,
1924
changeFilter,
@@ -28,6 +33,7 @@ import {
2833
import {setSearchValue, tenantsApi} from '../../store/reducers/tenants/tenants';
2934
import type {PreparedTenant} from '../../store/reducers/tenants/types';
3035
import type {AdditionalTenantsProps} from '../../types/additionalProps';
36+
import {uiFactory} from '../../uiFactory/uiFactory';
3137
import {cn} from '../../utils/cn';
3238
import {DEFAULT_TABLE_SETTINGS} from '../../utils/constants';
3339
import {
@@ -36,6 +42,9 @@ import {
3642
formatStorageValuesToGb,
3743
} from '../../utils/dataFormatters/dataFormatters';
3844
import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../utils/hooks';
45+
import {useClusterNameFromQuery} from '../../utils/hooks/useDatabaseFromQuery';
46+
47+
import i18n from './i18n';
3948

4049
import './Tenants.scss';
4150

@@ -50,13 +59,20 @@ interface TenantsProps {
5059
export const Tenants = ({additionalTenantsProps}: TenantsProps) => {
5160
const dispatch = useTypedDispatch();
5261

62+
const clusterName = useClusterNameFromQuery();
63+
5364
const [autoRefreshInterval] = useAutoRefreshInterval();
5465
const {currentData, isFetching, error} = tenantsApi.useGetTenantsInfoQuery(
5566
{clusterName},
5667
{pollingInterval: autoRefreshInterval},
5768
);
5869
const loading = isFetching && currentData === undefined;
5970

71+
const isCreateDBAvailable =
72+
useCreateDatabaseFeatureAvailable() && uiFactory.onCreateDB !== undefined;
73+
const isDeleteDBAvailable =
74+
useDeleteDatabaseFeatureAvailable() && uiFactory.onDeleteDB !== undefined;
75+
6076
const tenants = useTypedSelector((state) => selectTenants(state, clusterName));
6177
const searchValue = useTypedSelector(selectTenantsSearchValue);
6278
const filteredTenants = useTypedSelector((state) => selectFilteredTenants(state, clusterName));
@@ -70,6 +86,23 @@ export const Tenants = ({additionalTenantsProps}: TenantsProps) => {
7086
dispatch(setSearchValue(value));
7187
};
7288

89+
const renderCreateDBButton = () => {
90+
if (isCreateDBAvailable && clusterName) {
91+
return (
92+
<Button
93+
view="action"
94+
onClick={() => uiFactory.onCreateDB?.({clusterName})}
95+
className={b('create-database')}
96+
>
97+
<Icon data={CirclePlus} />
98+
{i18n('create-database')}
99+
</Button>
100+
);
101+
}
102+
103+
return null;
104+
};
105+
73106
const renderControls = () => {
74107
return (
75108
<React.Fragment>
@@ -86,6 +119,7 @@ export const Tenants = ({additionalTenantsProps}: TenantsProps) => {
86119
label={'Databases'}
87120
loading={loading}
88121
/>
122+
{renderCreateDBButton()}
89123
</React.Fragment>
90124
);
91125
};
@@ -202,6 +236,53 @@ export const Tenants = ({additionalTenantsProps}: TenantsProps) => {
202236
},
203237
];
204238

239+
if (isDeleteDBAvailable) {
240+
columns.push({
241+
name: 'actions',
242+
header: '',
243+
width: 40,
244+
resizeable: false,
245+
align: DataTable.CENTER,
246+
render: ({row}) => {
247+
const databaseId = row.UserAttributes?.database_id;
248+
const databaseName = row.Name;
249+
250+
let menuItems: (DropdownMenuItem | DropdownMenuItem[])[] = [];
251+
252+
if (clusterName && databaseName && databaseId) {
253+
menuItems = [
254+
{
255+
text: i18n('remove'),
256+
iconStart: <TrashBin />,
257+
action: () => {
258+
uiFactory.onDeleteDB?.({
259+
clusterName,
260+
databaseId,
261+
databaseName,
262+
});
263+
},
264+
className: b('remove-db'),
265+
},
266+
];
267+
}
268+
269+
if (!menuItems.length) {
270+
return null;
271+
}
272+
return (
273+
<DropdownMenu
274+
defaultSwitcherProps={{
275+
view: 'flat',
276+
size: 's',
277+
pin: 'brick-brick',
278+
}}
279+
items={menuItems}
280+
/>
281+
);
282+
},
283+
});
284+
}
285+
205286
if (filteredTenants.length === 0 && problemFilter !== ProblemFilterValues.ALL) {
206287
return <Illustration name="thumbsUp" width="200" />;
207288
}
@@ -218,12 +299,16 @@ export const Tenants = ({additionalTenantsProps}: TenantsProps) => {
218299
};
219300

220301
return (
221-
<TableWithControlsLayout>
222-
<TableWithControlsLayout.Controls>{renderControls()}</TableWithControlsLayout.Controls>
223-
{error ? <ResponseError error={error} /> : null}
224-
<TableWithControlsLayout.Table loading={loading}>
225-
{currentData ? renderTable() : null}
226-
</TableWithControlsLayout.Table>
227-
</TableWithControlsLayout>
302+
<div className={b('table-wrapper')}>
303+
<TableWithControlsLayout>
304+
<TableWithControlsLayout.Controls className={b('controls')}>
305+
{renderControls()}
306+
</TableWithControlsLayout.Controls>
307+
{error ? <ResponseError error={error} /> : null}
308+
<TableWithControlsLayout.Table loading={loading}>
309+
{currentData ? renderTable() : null}
310+
</TableWithControlsLayout.Table>
311+
</TableWithControlsLayout>
312+
</div>
228313
);
229314
};

src/containers/Tenants/i18n/en.json

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"create-database": "Create database",
3+
"remove": "Remove"
4+
}

src/containers/Tenants/i18n/index.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {registerKeysets} from '../../../utils/i18n';
2+
3+
import en from './en.json';
4+
5+
const COMPONENT = 'ydb-tenants-table';
6+
7+
export default registerKeysets(COMPONENT, {en});

src/services/api/meta.ts

+9
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {metaBackend as META_BACKEND} from '../../store';
2+
import type {MetaCapabilitiesResponse} from '../../types/api/capabilities';
23
import type {
34
MetaBaseClusterInfo,
45
MetaBaseClusters,
@@ -15,6 +16,14 @@ export class MetaAPI extends BaseYdbAPI {
1516
return `${META_BACKEND ?? ''}${path}`;
1617
}
1718

19+
getMetaCapabilities() {
20+
return this.get<MetaCapabilitiesResponse>(
21+
this.getPath('/capabilities'),
22+
{},
23+
{timeout: 1000},
24+
);
25+
}
26+
1827
getClustersList(_?: never, {signal}: {signal?: AbortSignal} = {}) {
1928
return this.get<MetaClusters>(this.getPath('/meta/clusters'), null, {
2029
requestConfig: {signal},

src/store/reducers/capabilities/capabilities.ts

+26-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {createSelector} from '@reduxjs/toolkit';
22

3-
import type {Capability, SecuritySetting} from '../../../types/api/capabilities';
3+
import type {Capability, MetaCapability, SecuritySetting} from '../../../types/api/capabilities';
44
import type {AppDispatch, RootState} from '../../defaultStore';
55

66
import {api} from './../api';
@@ -19,6 +19,21 @@ export const capabilitiesApi = api.injectEndpoints({
1919
}
2020
},
2121
}),
22+
getMetaCapabilities: build.query({
23+
queryFn: async () => {
24+
try {
25+
if (!window.api.meta) {
26+
throw new Error('Method is not implemented.');
27+
}
28+
const data = await window.api.meta.getMetaCapabilities();
29+
return {data};
30+
} catch (error) {
31+
// If capabilities endpoint is not available, there will be an error
32+
// That means no new features are available
33+
return {error};
34+
}
35+
},
36+
}),
2237
}),
2338
overrideExisting: 'throw',
2439
});
@@ -60,3 +75,13 @@ export async function queryCapability(
6075

6176
return selectCapabilityVersion(getState(), capability, database) || 0;
6277
}
78+
79+
export const selectMetaCapabilities = capabilitiesApi.endpoints.getMetaCapabilities.select({});
80+
81+
export const selectMetaCapabilityVersion = createSelector(
82+
(state: RootState) => state,
83+
(_state: RootState, capability: MetaCapability) => capability,
84+
(state, capability) => {
85+
return selectMetaCapabilities(state).data?.Capabilities?.[capability];
86+
},
87+
);

src/store/reducers/capabilities/hooks.ts

+29-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import type {Capability, SecuritySetting} from '../../../types/api/capabilities';
1+
import type {Capability, MetaCapability, SecuritySetting} from '../../../types/api/capabilities';
22
import {useTypedSelector} from '../../../utils/hooks';
33
import {useDatabaseFromQuery} from '../../../utils/hooks/useDatabaseFromQuery';
44

55
import {
66
capabilitiesApi,
77
selectCapabilityVersion,
88
selectDatabaseCapabilities,
9+
selectMetaCapabilities,
10+
selectMetaCapabilityVersion,
911
selectSecuritySetting,
1012
} from './capabilities';
1113

@@ -20,6 +22,8 @@ export function useCapabilitiesLoaded() {
2022

2123
const {data, error} = useTypedSelector((state) => selectDatabaseCapabilities(state, database));
2224

25+
// If capabilities endpoint is not available, request finishes with error
26+
// That means no new features are available
2327
return Boolean(data || error);
2428
}
2529

@@ -87,3 +91,27 @@ export const useClusterWithoutAuthInUI = () => {
8791
export const useLoginWithDatabase = () => {
8892
return useGetSecuritySetting('DomainLoginOnly') === false;
8993
};
94+
95+
export function useMetaCapabilitiesQuery() {
96+
capabilitiesApi.useGetMetaCapabilitiesQuery({});
97+
}
98+
99+
export function useMetaCapabilitiesLoaded() {
100+
const {data, error} = useTypedSelector(selectMetaCapabilities);
101+
102+
// If capabilities endpoint is not available, request finishes with error
103+
// That means no new features are available
104+
return Boolean(data || error);
105+
}
106+
107+
const useGetMetaFeatureVersion = (feature: MetaCapability) => {
108+
return useTypedSelector((state) => selectMetaCapabilityVersion(state, feature) || 0);
109+
};
110+
111+
export const useCreateDatabaseFeatureAvailable = () => {
112+
return useGetMetaFeatureVersion('/meta/create_database') >= 1;
113+
};
114+
115+
export const useDeleteDatabaseFeatureAvailable = () => {
116+
return useGetMetaFeatureVersion('/meta/delete_database') >= 1;
117+
};

src/types/api/capabilities.ts

+20
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,23 @@ export type Capability =
1919
| '/viewer/nodes';
2020

2121
export type SecuritySetting = 'UseLoginProvider' | 'DomainLoginOnly';
22+
23+
export interface MetaCapabilitiesResponse {
24+
Capabilities: Record<Partial<MetaCapability>, number>;
25+
}
26+
27+
export type MetaCapability =
28+
| '/meta/clusters'
29+
| '/meta/db_clusters'
30+
| '/meta/cp_databases'
31+
| '/meta/get_config'
32+
| '/meta/get_operation'
33+
| '/meta/list_operations'
34+
| '/meta/list_storage_types'
35+
| '/meta/list_resource_presets'
36+
| '/meta/create_database'
37+
| '/meta/update_database'
38+
| '/meta/delete_database'
39+
| '/meta/simulate_database'
40+
| '/meta/start_database'
41+
| '/meta/stop_database';

src/types/api/tenant.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface TTenant {
3636
Owner?: string;
3737
Users?: string[];
3838
PoolStats?: TPoolStats[];
39-
UserAttributes?: Record<string, string>;
39+
UserAttributes?: UserAttributes;
4040
Overall?: EFlag;
4141
SystemTablets?: TTabletStateInfo[];
4242
ResourceId?: string;
@@ -127,6 +127,14 @@ export interface TTenantResource {
127127
/** incomplete */
128128
export interface ControlPlane {
129129
name?: string;
130+
id?: string;
131+
endpoint?: string;
132+
folder_id?: string;
133+
}
134+
/** incomplete */
135+
interface UserAttributes {
136+
database_id?: string;
137+
folder_id?: string;
130138
}
131139

132140
export type ETenantType = 'UnknownTenantType' | 'Domain' | 'Dedicated' | 'Shared' | 'Serverless';

0 commit comments

Comments
 (0)