From 604b788d209b08c71f4ee2bb0df53f66e61b8bdd Mon Sep 17 00:00:00 2001 From: mufazalov Date: Wed, 23 Apr 2025 17:31:11 +0300 Subject: [PATCH 1/2] feat: add endpont to connect to db code snippets --- .../ConnectToDB/ConnectToDBDialog.tsx | 38 +++++++++++++++---- .../ConnectToDB/__test__/utils.test.ts | 34 +++++++++++++++++ src/components/ConnectToDB/snippets.ts | 8 +++- src/components/ConnectToDB/utils.ts | 20 ++++++++++ src/containers/Header/Header.tsx | 10 ++--- .../TenantOverview/TenantOverview.tsx | 8 ++-- .../ObjectSummary/SchemaTree/SchemaTree.tsx | 9 +---- src/services/api/meta.ts | 6 ++- src/services/api/viewer.ts | 2 +- src/store/reducers/cluster/cluster.ts | 18 ++++++--- src/store/reducers/tenant/tenant.ts | 16 +++++++- src/store/reducers/tenants/tenants.ts | 4 +- 12 files changed, 137 insertions(+), 36 deletions(-) create mode 100644 src/components/ConnectToDB/__test__/utils.test.ts create mode 100644 src/components/ConnectToDB/utils.ts diff --git a/src/components/ConnectToDB/ConnectToDBDialog.tsx b/src/components/ConnectToDB/ConnectToDBDialog.tsx index e6636974ee..5e697bf403 100644 --- a/src/components/ConnectToDB/ConnectToDBDialog.tsx +++ b/src/components/ConnectToDB/ConnectToDBDialog.tsx @@ -2,9 +2,14 @@ import React from 'react'; import NiceModal from '@ebay/nice-modal-react'; import {Dialog, Tabs} from '@gravity-ui/uikit'; +import {skipToken} from '@reduxjs/toolkit/query'; +import {tenantApi} from '../../store/reducers/tenant/tenant'; import {cn} from '../../utils/cn'; +import {useTypedSelector} from '../../utils/hooks'; +import {useClusterNameFromQuery} from '../../utils/hooks/useDatabaseFromQuery'; import {LinkWithIcon} from '../LinkWithIcon/LinkWithIcon'; +import {LoaderWrapper} from '../LoaderWrapper/LoaderWrapper'; import {YDBSyntaxHighlighterLazy} from '../SyntaxHighlighter/lazy'; import {getDocsLink} from './getDocsLink'; @@ -32,9 +37,26 @@ interface ConnectToDBDialogProps extends SnippetParams { onClose: VoidFunction; } -function ConnectToDBDialog({open, onClose, database, endpoint}: ConnectToDBDialogProps) { +function ConnectToDBDialog({ + open, + onClose, + database, + endpoint: endpointFromProps, +}: ConnectToDBDialogProps) { const [activeTab, setActiveTab] = React.useState('bash'); + const clusterName = useClusterNameFromQuery(); + const singleClusterMode = useTypedSelector((state) => state.singleClusterMode); + + // If there is enpdoint from props, we don't need to request tenant data + // Also we should not request tenant data if we are in single cluster mode + // Since there is no ControlPlane data in this case + const shouldRequestTenantData = database && !endpointFromProps && !singleClusterMode; + const params = shouldRequestTenantData ? {path: database, clusterName} : skipToken; + const {currentData: tenantData, isLoading: isTenantDataLoading} = + tenantApi.useGetTenantInfoQuery(params); + const endpoint = endpointFromProps ?? tenantData?.ControlPlane?.endpoint; + const snippet = getSnippetCode(activeTab, {database, endpoint}); const docsLink = getDocsLink(activeTab); @@ -52,12 +74,14 @@ function ConnectToDBDialog({open, onClose, database, endpoint}: ConnectToDBDialo className={b('dialog-tabs')} />
- + + +
{docsLink ? ( { + test('should remove all search params', () => { + const input = 'grpc://example.com:2139/?database=/root/test¶m=value'; + const expected = 'grpc://example.com:2139'; + expect(prepareEndpoint(input)).toBe(expected); + }); + test('should handle URL without path or params', () => { + const input = 'grpc://example.com:2139'; + const expected = 'grpc://example.com:2139'; + expect(prepareEndpoint(input)).toBe(expected); + }); + test('should remove trailing slash from path', () => { + const input = 'grpc://example.com:2139/'; + const expected = 'grpc://example.com:2139'; + expect(prepareEndpoint(input)).toBe(expected); + }); + test('should handle complex paths', () => { + const input = 'grpc://example.com:2139/multi/level/path/?database=/root/test'; + const expected = 'grpc://example.com:2139/multi/level/path'; + expect(prepareEndpoint(input)).toBe(expected); + }); + test('should handle empty string', () => { + expect(prepareEndpoint('')).toBeUndefined(); + }); + test('should handle undefined input', () => { + expect(prepareEndpoint()).toBeUndefined(); + }); + test('should return undefined for invalid URL', () => { + const input = 'invalid-url'; + expect(prepareEndpoint(input)).toBeUndefined(); + }); +}); diff --git a/src/components/ConnectToDB/snippets.ts b/src/components/ConnectToDB/snippets.ts index 4ef14ace81..c794ce89bf 100644 --- a/src/components/ConnectToDB/snippets.ts +++ b/src/components/ConnectToDB/snippets.ts @@ -1,4 +1,5 @@ import type {SnippetLanguage, SnippetParams} from './types'; +import {prepareEndpoint} from './utils'; export function getBashSnippetCode({database, endpoint}: SnippetParams) { return `ydb -e ${endpoint || ''} --token-file ~/my_token @@ -198,7 +199,12 @@ with ydb.Driver(driver_config) as driver: print(driver.discovery_debug_details())`; } -export function getSnippetCode(lang: SnippetLanguage, params: SnippetParams) { +export function getSnippetCode(lang: SnippetLanguage, rawParams: SnippetParams) { + const params = { + ...rawParams, + endpoint: prepareEndpoint(rawParams.endpoint), + }; + switch (lang) { case 'cpp': { return getCPPSnippetCode(params); diff --git a/src/components/ConnectToDB/utils.ts b/src/components/ConnectToDB/utils.ts new file mode 100644 index 0000000000..c9261e8ef2 --- /dev/null +++ b/src/components/ConnectToDB/utils.ts @@ -0,0 +1,20 @@ +// We have endpoint in format grpc://example.com:2139/?database=/root/test +// We need it to be like grpc://example.com:2139 to make code in snippets work +// We pass database to snippets as a separate param +export function prepareEndpoint(connectionString = '') { + try { + const urlObj = new URL(connectionString); + urlObj.search = ''; + + let endpoint = urlObj.toString(); + + // Remove trailing slash if present + if (endpoint.endsWith('/')) { + endpoint = endpoint.slice(0, -1); + } + + return endpoint; + } catch { + return undefined; + } +} diff --git a/src/containers/Header/Header.tsx b/src/containers/Header/Header.tsx index 7ddae7f9dd..baedcc00c6 100644 --- a/src/containers/Header/Header.tsx +++ b/src/containers/Header/Header.tsx @@ -30,14 +30,12 @@ function Header({mainPage}: HeaderProps) { const {page, pageBreadcrumbsOptions} = useTypedSelector((state) => state.header); const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); - const clusterInfo = useClusterBaseInfo(); + const {title: clusterTitle} = useClusterBaseInfo(); const database = useDatabaseFromQuery(); const location = useLocation(); const isDatabasePage = location.pathname === '/tenant'; - const clusterName = clusterInfo.title || clusterInfo.name; - const breadcrumbItems = React.useMemo(() => { const rawBreadcrumbs: RawBreadcrumbItem[] = []; let options = pageBreadcrumbsOptions; @@ -46,10 +44,10 @@ function Header({mainPage}: HeaderProps) { rawBreadcrumbs.push(mainPage); } - if (clusterName) { + if (clusterTitle) { options = { ...options, - clusterName, + clusterName: clusterTitle, }; } @@ -58,7 +56,7 @@ function Header({mainPage}: HeaderProps) { return breadcrumbs.map((item) => { return {...item, action: () => {}}; }); - }, [clusterName, mainPage, page, pageBreadcrumbsOptions]); + }, [clusterTitle, mainPage, page, pageBreadcrumbsOptions]); const renderRightControls = () => { const elements: React.ReactNode[] = []; diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx index 6c0a981fbb..1a82e28b4a 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx @@ -10,6 +10,7 @@ import {calculateTenantMetrics} from '../../../../store/reducers/tenants/utils'; import type {AdditionalNodesProps, AdditionalTenantsProps} from '../../../../types/additionalProps'; import {TENANT_DEFAULT_TITLE} from '../../../../utils/constants'; import {useAutoRefreshInterval, useTypedSelector} from '../../../../utils/hooks'; +import {useClusterNameFromQuery} from '../../../../utils/hooks/useDatabaseFromQuery'; import {mapDatabaseTypeToDBName} from '../../utils/schema'; import {DefaultOverviewContent} from './DefaultOverviewContent/DefaultOverviewContent'; @@ -35,12 +36,11 @@ export function TenantOverview({ }: TenantOverviewProps) { const {metricsTab} = useTypedSelector((state) => state.tenant); const [autoRefreshInterval] = useAutoRefreshInterval(); + const clusterName = useClusterNameFromQuery(); const {currentData: tenant, isFetching} = tenantApi.useGetTenantInfoQuery( - {path: tenantName}, - { - pollingInterval: autoRefreshInterval, - }, + {path: tenantName, clusterName}, + {pollingInterval: autoRefreshInterval}, ); const tenantLoading = isFetching && tenant === undefined; const {Name, Type, Overall} = tenant || {}; diff --git a/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx b/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx index 4f9b8070f0..3d5b9541e9 100644 --- a/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx +++ b/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx @@ -12,11 +12,7 @@ import {schemaApi} from '../../../../store/reducers/schema/schema'; import {tableSchemaDataApi} from '../../../../store/reducers/tableSchemaData'; import type {EPathType, TEvDescribeSchemeResult} from '../../../../types/api/schema'; import {valueIsDefined} from '../../../../utils'; -import { - useQueryExecutionSettings, - useTypedDispatch, - useTypedSelector, -} from '../../../../utils/hooks'; +import {useTypedDispatch, useTypedSelector} from '../../../../utils/hooks'; import {getConfirmation} from '../../../../utils/hooks/withConfirmation/useChangeInputWithConfirmation'; import {getSchemaControls} from '../../utils/controls'; import { @@ -48,7 +44,6 @@ export function SchemaTree(props: SchemaTreeProps) { {currentData: actionsSchemaData, isFetching: isActionsDataFetching}, ] = tableSchemaDataApi.useLazyGetTableSchemaDataQuery(); - const [querySettings] = useQueryExecutionSettings(); const [createDirectoryOpen, setCreateDirectoryOpen] = React.useState(false); const [parentPath, setParentPath] = React.useState(''); const setSchemaTreeKey = useDispatchTreeKey(); @@ -144,8 +139,8 @@ export function SchemaTree(props: SchemaTreeProps) { dispatch, input, isActionsDataFetching, + isDirty, onActivePathUpdate, - querySettings, rootPath, ]); diff --git a/src/services/api/meta.ts b/src/services/api/meta.ts index 2518af8061..025849ed5f 100644 --- a/src/services/api/meta.ts +++ b/src/services/api/meta.ts @@ -30,11 +30,15 @@ export class MetaAPI extends BaseYdbAPI { }); } - getTenants(clusterName?: string, {signal}: AxiosOptions = {}) { + getTenants( + {clusterName, databaseName}: {clusterName?: string; databaseName?: string}, + {signal}: AxiosOptions = {}, + ) { return this.get( this.getPath('/meta/cp_databases'), { cluster_name: clusterName, + database_name: databaseName, }, {requestConfig: {signal}}, ).then(parseMetaTenants); diff --git a/src/services/api/viewer.ts b/src/services/api/viewer.ts index a676175c98..d106eda36c 100644 --- a/src/services/api/viewer.ts +++ b/src/services/api/viewer.ts @@ -65,7 +65,7 @@ export class ViewerAPI extends BaseYdbAPI { ); } - getTenants(clusterName?: string, {concurrentId, signal}: AxiosOptions = {}) { + getTenants({clusterName}: {clusterName?: string}, {concurrentId, signal}: AxiosOptions = {}) { return this.get( this.getPath('/viewer/json/tenantinfo'), { diff --git a/src/store/reducers/cluster/cluster.ts b/src/store/reducers/cluster/cluster.ts index 753d5d7dc6..cdd53c70e7 100644 --- a/src/store/reducers/cluster/cluster.ts +++ b/src/store/reducers/cluster/cluster.ts @@ -1,7 +1,6 @@ import {createSelector, createSlice} from '@reduxjs/toolkit'; import type {Dispatch, PayloadAction} from '@reduxjs/toolkit'; import {skipToken} from '@reduxjs/toolkit/query'; -import {StringParam, useQueryParam} from 'use-query-params'; import type {ClusterTab} from '../../../containers/Cluster/utils'; import {clusterTabsIds, isClusterTab} from '../../../containers/Cluster/utils'; @@ -10,6 +9,7 @@ import {isClusterInfoV2} from '../../../types/api/cluster'; import type {TClusterInfo} from '../../../types/api/cluster'; import type {TTabletStateInfo} from '../../../types/api/tablet'; import {CLUSTER_DEFAULT_TITLE, DEFAULT_CLUSTER_TAB_KEY} from '../../../utils/constants'; +import {useClusterNameFromQuery} from '../../../utils/hooks/useDatabaseFromQuery'; import {isQueryErrorResponse} from '../../../utils/query'; import type {RootState} from '../../defaultStore'; import {api} from '../api'; @@ -136,16 +136,24 @@ export const clusterApi = api.injectEndpoints({ }); export function useClusterBaseInfo() { - const [clusterName] = useQueryParam('clusterName', StringParam); + const clusterNameFromQuery = useClusterNameFromQuery(); - const {currentData} = clusterApi.useGetClusterBaseInfoQuery(clusterName ?? skipToken); + const {currentData} = clusterApi.useGetClusterBaseInfoQuery(clusterNameFromQuery ?? skipToken); - const {solomon: monitoring, name, trace_view: traceView, ...data} = currentData || {}; + const {solomon: monitoring, name, title, trace_view: traceView, ...data} = currentData || {}; + + // name is used for requests, title is used for display + // Example: + // Name: ydb_vla_dev02 + // Title: YDB DEV VLA02 + const clusterName = name ?? clusterNameFromQuery ?? undefined; + const clusterTitle = title ?? clusterName; return { ...data, ...parseTraceFields({traceView}), - name: name ?? clusterName ?? undefined, + name: clusterName, + title: clusterTitle, monitoring, }; } diff --git a/src/store/reducers/tenant/tenant.ts b/src/store/reducers/tenant/tenant.ts index b1e5edf31e..56460b321d 100644 --- a/src/store/reducers/tenant/tenant.ts +++ b/src/store/reducers/tenant/tenant.ts @@ -2,6 +2,7 @@ import {createSlice} from '@reduxjs/toolkit'; import type {PayloadAction} from '@reduxjs/toolkit'; import {DEFAULT_USER_SETTINGS, settingsManager} from '../../../services/settings'; +import type {TTenantInfo} from '../../../types/api/tenant'; import {TENANT_INITIAL_PAGE_KEY} from '../../../utils/constants'; import {api} from '../api'; @@ -52,9 +53,20 @@ export const {setTenantPage, setQueryTab, setDiagnosticsTab, setSummaryTab, setM export const tenantApi = api.injectEndpoints({ endpoints: (builder) => ({ getTenantInfo: builder.query({ - queryFn: async ({path}: {path: string}, {signal}) => { + queryFn: async ( + {path, clusterName}: {path: string; clusterName?: string}, + {signal}, + ) => { try { - const tenantData = await window.api.viewer.getTenantInfo({path}, {signal}); + let tenantData: TTenantInfo; + if (window.api.meta && clusterName) { + tenantData = await window.api.meta.getTenants( + {databaseName: path, clusterName}, + {signal}, + ); + } else { + tenantData = await window.api.viewer.getTenantInfo({path}, {signal}); + } return {data: tenantData.TenantInfo?.[0] ?? null}; } catch (error) { return {error}; diff --git a/src/store/reducers/tenants/tenants.ts b/src/store/reducers/tenants/tenants.ts index d4e2a3322f..20856730d4 100644 --- a/src/store/reducers/tenants/tenants.ts +++ b/src/store/reducers/tenants/tenants.ts @@ -27,8 +27,8 @@ export const tenantsApi = api.injectEndpoints({ queryFn: async ({clusterName}: {clusterName?: string}, {signal}) => { try { const response = window.api.meta - ? await window.api.meta.getTenants(clusterName, {signal}) - : await window.api.viewer.getTenants(clusterName, {signal}); + ? await window.api.meta.getTenants({clusterName}, {signal}) + : await window.api.viewer.getTenants({clusterName}, {signal}); let data: PreparedTenant[]; if (Array.isArray(response.TenantInfo)) { data = prepareTenants(response.TenantInfo); From 5949e61a95e66fba37c42b2d4726a6e0eb7428fa Mon Sep 17 00:00:00 2001 From: mufazalov Date: Wed, 23 Apr 2025 18:14:21 +0300 Subject: [PATCH 2/2] fix: fix typo --- src/components/ConnectToDB/ConnectToDBDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ConnectToDB/ConnectToDBDialog.tsx b/src/components/ConnectToDB/ConnectToDBDialog.tsx index 5e697bf403..d5c8c54a05 100644 --- a/src/components/ConnectToDB/ConnectToDBDialog.tsx +++ b/src/components/ConnectToDB/ConnectToDBDialog.tsx @@ -48,7 +48,7 @@ function ConnectToDBDialog({ const clusterName = useClusterNameFromQuery(); const singleClusterMode = useTypedSelector((state) => state.singleClusterMode); - // If there is enpdoint from props, we don't need to request tenant data + // If there is endpoint from props, we don't need to request tenant data // Also we should not request tenant data if we are in single cluster mode // Since there is no ControlPlane data in this case const shouldRequestTenantData = database && !endpointFromProps && !singleClusterMode;