Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/containers/App/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
useCapabilitiesLoaded,
useCapabilitiesQuery,
useClusterWithoutAuthInUI,
useMetaCapabilitiesLoaded,
useMetaCapabilitiesQuery,
} from '../../store/reducers/capabilities/hooks';
import {nodesListApi} from '../../store/reducers/nodesList';
import {cn} from '../../utils/cn';
Expand Down Expand Up @@ -213,8 +215,12 @@ function GetCapabilities({children}: {children: React.ReactNode}) {
useCapabilitiesQuery();
const capabilitiesLoaded = useCapabilitiesLoaded();

useMetaCapabilitiesQuery();
// It is always true if there is no meta, since request finishes with an error
const metaCapabilitiesLoaded = useMetaCapabilitiesLoaded();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did not dig deep but looks like if there is no meta metaCapabilitiesLoaded will always be falsy and loading will always be true?


return (
<LoaderWrapper loading={!capabilitiesLoaded} size="l">
<LoaderWrapper loading={!capabilitiesLoaded || !metaCapabilitiesLoaded} size="l">
{children}
</LoaderWrapper>
);
Expand Down
19 changes: 19 additions & 0 deletions src/containers/Tenants/Tenants.scss
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,23 @@
&__name {
overflow: hidden;
}

&__controls {
width: 100%;
}

&__table-wrapper {
width: max-content;
}

&__create-database {
position: sticky;
right: 0;

margin: 0 0 0 auto;
}

&__remove-db {
color: var(--ydb-color-status-red);
}
}
103 changes: 94 additions & 9 deletions src/containers/Tenants/Tenants.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React from 'react';

import {CirclePlus, TrashBin} from '@gravity-ui/icons';
import type {Column} from '@gravity-ui/react-data-table';
import DataTable from '@gravity-ui/react-data-table';
import {Button} from '@gravity-ui/uikit';
import type {DropdownMenuItem} from '@gravity-ui/uikit';
import {Button, DropdownMenu, Icon} from '@gravity-ui/uikit';

import {EntitiesCount} from '../../components/EntitiesCount';
import {ResponseError} from '../../components/Errors/ResponseError';
Expand All @@ -13,7 +15,10 @@ import {ResizeableDataTable} from '../../components/ResizeableDataTable/Resizeab
import {Search} from '../../components/Search';
import {TableWithControlsLayout} from '../../components/TableWithControlsLayout/TableWithControlsLayout';
import {TenantNameWrapper} from '../../components/TenantNameWrapper/TenantNameWrapper';
import {clusterName} from '../../store';
import {
useCreateDatabaseFeatureAvailable,
useDeleteDatabaseFeatureAvailable,
} from '../../store/reducers/capabilities/hooks';
import {
ProblemFilterValues,
changeFilter,
Expand All @@ -28,6 +33,7 @@ import {
import {setSearchValue, tenantsApi} from '../../store/reducers/tenants/tenants';
import type {PreparedTenant} from '../../store/reducers/tenants/types';
import type {AdditionalTenantsProps} from '../../types/additionalProps';
import {uiFactory} from '../../uiFactory/uiFactory';
import {cn} from '../../utils/cn';
import {DEFAULT_TABLE_SETTINGS} from '../../utils/constants';
import {
Expand All @@ -36,6 +42,9 @@ import {
formatStorageValuesToGb,
} from '../../utils/dataFormatters/dataFormatters';
import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../utils/hooks';
import {useClusterNameFromQuery} from '../../utils/hooks/useDatabaseFromQuery';

import i18n from './i18n';

import './Tenants.scss';

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

const clusterName = useClusterNameFromQuery();

const [autoRefreshInterval] = useAutoRefreshInterval();
const {currentData, isFetching, error} = tenantsApi.useGetTenantsInfoQuery(
{clusterName},
{pollingInterval: autoRefreshInterval},
);
const loading = isFetching && currentData === undefined;

const isCreateDBAvailable =
useCreateDatabaseFeatureAvailable() && uiFactory.onCreateDB !== undefined;
const isDeleteDBAvailable =
useDeleteDatabaseFeatureAvailable() && uiFactory.onDeleteDB !== undefined;

const tenants = useTypedSelector((state) => selectTenants(state, clusterName));
const searchValue = useTypedSelector(selectTenantsSearchValue);
const filteredTenants = useTypedSelector((state) => selectFilteredTenants(state, clusterName));
Expand All @@ -70,6 +86,23 @@ export const Tenants = ({additionalTenantsProps}: TenantsProps) => {
dispatch(setSearchValue(value));
};

const renderCreateDBButton = () => {
if (isCreateDBAvailable && clusterName) {
return (
<Button
view="action"
onClick={() => uiFactory.onCreateDB?.({clusterName})}
className={b('create-database')}
>
<Icon data={CirclePlus} />
{i18n('create-database')}
</Button>
);
}

return null;
};

const renderControls = () => {
return (
<React.Fragment>
Expand All @@ -86,6 +119,7 @@ export const Tenants = ({additionalTenantsProps}: TenantsProps) => {
label={'Databases'}
loading={loading}
/>
{renderCreateDBButton()}
</React.Fragment>
);
};
Expand Down Expand Up @@ -202,6 +236,53 @@ export const Tenants = ({additionalTenantsProps}: TenantsProps) => {
},
];

if (isDeleteDBAvailable) {
columns.push({
name: 'actions',
header: '',
width: 40,
resizeable: false,
align: DataTable.CENTER,
render: ({row}) => {
const databaseId = row.UserAttributes?.database_id;
const databaseName = row.Name;

let menuItems: (DropdownMenuItem | DropdownMenuItem[])[] = [];

if (clusterName && databaseName && databaseId) {
menuItems = [
{
text: i18n('remove'),
iconStart: <TrashBin />,
action: () => {
uiFactory.onDeleteDB?.({
clusterName,
databaseId,
databaseName,
});
},
className: b('remove-db'),
},
];
}

if (!menuItems.length) {
return null;
}
return (
<DropdownMenu
defaultSwitcherProps={{
view: 'flat',
size: 's',
pin: 'brick-brick',
}}
items={menuItems}
/>
);
},
});
}

if (filteredTenants.length === 0 && problemFilter !== ProblemFilterValues.ALL) {
return <Illustration name="thumbsUp" width="200" />;
}
Expand All @@ -218,12 +299,16 @@ export const Tenants = ({additionalTenantsProps}: TenantsProps) => {
};

return (
<TableWithControlsLayout>
<TableWithControlsLayout.Controls>{renderControls()}</TableWithControlsLayout.Controls>
{error ? <ResponseError error={error} /> : null}
<TableWithControlsLayout.Table loading={loading}>
{currentData ? renderTable() : null}
</TableWithControlsLayout.Table>
</TableWithControlsLayout>
<div className={b('table-wrapper')}>
<TableWithControlsLayout>
<TableWithControlsLayout.Controls className={b('controls')}>
{renderControls()}
</TableWithControlsLayout.Controls>
{error ? <ResponseError error={error} /> : null}
<TableWithControlsLayout.Table loading={loading}>
{currentData ? renderTable() : null}
</TableWithControlsLayout.Table>
</TableWithControlsLayout>
</div>
);
};
4 changes: 4 additions & 0 deletions src/containers/Tenants/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"create-database": "Create database",
"remove": "Remove"
}
7 changes: 7 additions & 0 deletions src/containers/Tenants/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {registerKeysets} from '../../../utils/i18n';

import en from './en.json';

const COMPONENT = 'ydb-tenants-table';

export default registerKeysets(COMPONENT, {en});
9 changes: 9 additions & 0 deletions src/services/api/meta.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {metaBackend as META_BACKEND} from '../../store';
import type {MetaCapabilitiesResponse} from '../../types/api/capabilities';
import type {
MetaBaseClusterInfo,
MetaBaseClusters,
Expand All @@ -15,6 +16,14 @@ export class MetaAPI extends BaseYdbAPI {
return `${META_BACKEND ?? ''}${path}`;
}

getMetaCapabilities() {
return this.get<MetaCapabilitiesResponse>(
this.getPath('/capabilities'),
{},
{timeout: 1000},
);
}

getClustersList(_?: never, {signal}: {signal?: AbortSignal} = {}) {
return this.get<MetaClusters>(this.getPath('/meta/clusters'), null, {
requestConfig: {signal},
Expand Down
27 changes: 26 additions & 1 deletion src/store/reducers/capabilities/capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {createSelector} from '@reduxjs/toolkit';

import type {Capability, SecuritySetting} from '../../../types/api/capabilities';
import type {Capability, MetaCapability, SecuritySetting} from '../../../types/api/capabilities';
import type {AppDispatch, RootState} from '../../defaultStore';

import {api} from './../api';
Expand All @@ -19,6 +19,21 @@ export const capabilitiesApi = api.injectEndpoints({
}
},
}),
getMetaCapabilities: build.query({
queryFn: async () => {
try {
if (!window.api.meta) {
throw new Error('Method is not implemented.');
}
const data = await window.api.meta.getMetaCapabilities();
return {data};
} catch (error) {
// If capabilities endpoint is not available, there will be an error
// That means no new features are available
return {error};
}
},
}),
}),
overrideExisting: 'throw',
});
Expand Down Expand Up @@ -60,3 +75,13 @@ export async function queryCapability(

return selectCapabilityVersion(getState(), capability, database) || 0;
}

export const selectMetaCapabilities = capabilitiesApi.endpoints.getMetaCapabilities.select({});

export const selectMetaCapabilityVersion = createSelector(
(state: RootState) => state,
(_state: RootState, capability: MetaCapability) => capability,
(state, capability) => {
return selectMetaCapabilities(state).data?.Capabilities?.[capability];
},
);
30 changes: 29 additions & 1 deletion src/store/reducers/capabilities/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type {Capability, SecuritySetting} from '../../../types/api/capabilities';
import type {Capability, MetaCapability, SecuritySetting} from '../../../types/api/capabilities';
import {useTypedSelector} from '../../../utils/hooks';
import {useDatabaseFromQuery} from '../../../utils/hooks/useDatabaseFromQuery';

import {
capabilitiesApi,
selectCapabilityVersion,
selectDatabaseCapabilities,
selectMetaCapabilities,
selectMetaCapabilityVersion,
selectSecuritySetting,
} from './capabilities';

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

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

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

Expand Down Expand Up @@ -87,3 +91,27 @@ export const useClusterWithoutAuthInUI = () => {
export const useLoginWithDatabase = () => {
return useGetSecuritySetting('DomainLoginOnly') === false;
};

export function useMetaCapabilitiesQuery() {
capabilitiesApi.useGetMetaCapabilitiesQuery({});
}

export function useMetaCapabilitiesLoaded() {
const {data, error} = useTypedSelector(selectMetaCapabilities);

// If capabilities endpoint is not available, request finishes with error
// That means no new features are available
return Boolean(data || error);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as for me its not that obvious that Loaded is true when we receive error

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For capabilities queries error is a valid state and it means that there is no capability endpoint in current cluster / meta cluster. In that case all capabilities considered not available (version 0)

The alternative for this code (return data: {} instead of error in catch block):

 getMetaCapabilities: build.query({
            queryFn: async () => {
                try {
                    if (!window.api.meta) {
                        throw new Error('Method is not implemented.');
                    }
                    const data = await window.api.meta.getMetaCapabilities();
                    return {data};
                } catch {
                    // If capabilities endpoint is not available, there will be an error
                    // That means no new features are available
                    return {data: {}};
                }
            },
        }),

What do you think, will it be more clear?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to handle real errors from this endpoint?

I mean now HTTP 500 and non-existent state are the same data: {}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so.

In case of error we don't know anything about cluster capabilities, so it's much safer to consider everything disabled.

If response is 500, we can only retry, but we do not retry requests currently

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as for me I would split loaded and availability state like

isMetaCapabilitiesAvailable=Boolean(window.api.meta.getMetaCapabilities)

loading={!isCapabilitiesLoaded || (isMetaCapabilitiesAvailable && !isMetaCapabilitiesLoaded)}

because when I see just !isMetaCapabilitiesLoaded I am expecting for it to be loaded

but thats just me and just readability issue

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to add additional code, but added some comments, I hope they will help

}

const useGetMetaFeatureVersion = (feature: MetaCapability) => {
return useTypedSelector((state) => selectMetaCapabilityVersion(state, feature) || 0);
};

export const useCreateDatabaseFeatureAvailable = () => {
return useGetMetaFeatureVersion('/meta/create_database') >= 1;
};

export const useDeleteDatabaseFeatureAvailable = () => {
return useGetMetaFeatureVersion('/meta/delete_database') >= 1;
};
20 changes: 20 additions & 0 deletions src/types/api/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,23 @@ export type Capability =
| '/viewer/nodes';

export type SecuritySetting = 'UseLoginProvider' | 'DomainLoginOnly';

export interface MetaCapabilitiesResponse {
Capabilities: Record<Partial<MetaCapability>, number>;
}

export type MetaCapability =
| '/meta/clusters'
| '/meta/db_clusters'
| '/meta/cp_databases'
| '/meta/get_config'
| '/meta/get_operation'
| '/meta/list_operations'
| '/meta/list_storage_types'
| '/meta/list_resource_presets'
| '/meta/create_database'
| '/meta/update_database'
| '/meta/delete_database'
| '/meta/simulate_database'
| '/meta/start_database'
| '/meta/stop_database';
10 changes: 9 additions & 1 deletion src/types/api/tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export interface TTenant {
Owner?: string;
Users?: string[];
PoolStats?: TPoolStats[];
UserAttributes?: Record<string, string>;
UserAttributes?: UserAttributes;
Overall?: EFlag;
SystemTablets?: TTabletStateInfo[];
ResourceId?: string;
Expand Down Expand Up @@ -127,6 +127,14 @@ export interface TTenantResource {
/** incomplete */
export interface ControlPlane {
name?: string;
id?: string;
endpoint?: string;
folder_id?: string;
}
/** incomplete */
interface UserAttributes {
database_id?: string;
folder_id?: string;
}

export type ETenantType = 'UnknownTenantType' | 'Domain' | 'Dedicated' | 'Shared' | 'Serverless';
Expand Down
Loading
Loading