diff --git a/package.json b/package.json index 92a1aeddf..e321d5107 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "build": "rm -rf build && DISABLE_ESLINT_PLUGIN=true CI=true react-app-rewired build", "//build:embedded": "echo 'PUBLIC_URL is a setting for create-react-app. Embedded version is built and hosted as is on ydb servers, with no way of knowing the final URL pattern. PUBLIC_URL=. keeps paths to all static relative, allowing servers to handle them as needed'", "build:embedded": "GENERATE_SOURCEMAP=false PUBLIC_URL=. REACT_APP_BACKEND=http://localhost:8765 REACT_APP_META_BACKEND=undefined npm run build", + "build:embedded-mc": "GENERATE_SOURCEMAP=false PUBLIC_URL=. REACT_APP_BACKEND= REACT_APP_META_BACKEND= npm run build", "build:embedded:archive": "npm run build:embedded && mv build embedded-ui && cp CHANGELOG.md embedded-ui/CHANGELOG.md && zip -r embedded-ui.zip embedded-ui && rm -rf embedded-ui", "lint": "run-p lint:*", "lint:js": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/src/components/TenantNameWrapper/TenantNameWrapper.tsx b/src/components/TenantNameWrapper/TenantNameWrapper.tsx index 0b72da100..78afc779f 100644 --- a/src/components/TenantNameWrapper/TenantNameWrapper.tsx +++ b/src/components/TenantNameWrapper/TenantNameWrapper.tsx @@ -67,10 +67,13 @@ export function TenantNameWrapper({tenant, additionalTenantsProps}: TenantNameWr status={tenant.Overall} infoPopoverContent={infoPopoverContent} hasClipboardButton - path={getTenantPath({ - database: tenant.Name, - backend, - })} + path={getTenantPath( + { + database: tenant.Name, + backend, + }, + {withBasename: isExternalLink}, + )} /> ); } diff --git a/src/containers/Cluster/utils.tsx b/src/containers/Cluster/utils.tsx index 68358552e..bdb01840d 100644 --- a/src/containers/Cluster/utils.tsx +++ b/src/containers/Cluster/utils.tsx @@ -1,3 +1,4 @@ +import type {CreateHrefOptions} from '../../routes'; import routes, {createHref} from '../../routes'; import type {ValueOf} from '../../types/common'; @@ -44,6 +45,6 @@ export function isClusterTab(tab: any): tab is ClusterTab { return Object.values(clusterTabsIds).includes(tab); } -export const getClusterPath = (activeTab?: ClusterTab, query = {}) => { - return createHref(routes.cluster, activeTab ? {activeTab} : undefined, query); +export const getClusterPath = (activeTab?: ClusterTab, query = {}, options?: CreateHrefOptions) => { + return createHref(routes.cluster, activeTab ? {activeTab} : undefined, query, options); }; diff --git a/src/containers/Clusters/columns.tsx b/src/containers/Clusters/columns.tsx index 00f42623c..7da8200d7 100644 --- a/src/containers/Clusters/columns.tsx +++ b/src/containers/Clusters/columns.tsx @@ -36,7 +36,7 @@ export const CLUSTERS_COLUMNS: Column[] = [ const clusterPath = useEmbeddedUi && backend ? createDeveloperUIMonitoringPageHref(backend) - : getClusterPath(undefined, {backend, clusterName}); + : getClusterPath(undefined, {backend, clusterName}, {withBasename: true}); const clusterStatus = row.cluster?.Overall; @@ -110,7 +110,11 @@ export const CLUSTERS_COLUMNS: Column[] = [ preparedVersions.length > 0 && ( {preparedVersions.map((item, index) => ( diff --git a/src/containers/Tenant/TenantPages.tsx b/src/containers/Tenant/TenantPages.tsx index dc806fdc2..d19fd79f8 100644 --- a/src/containers/Tenant/TenantPages.tsx +++ b/src/containers/Tenant/TenantPages.tsx @@ -1,3 +1,4 @@ +import type {CreateHrefOptions} from '../../routes'; import routes, {createHref} from '../../routes'; import {TENANT_SUMMARY_TABS_IDS} from '../../store/reducers/tenant/constants'; import type {paramSetup} from '../../store/state-url-mapping'; @@ -40,6 +41,6 @@ export const TENANT_SCHEMA_TAB = [ }, ]; -export const getTenantPath = (query: TenantQuery) => { - return createHref(routes.tenant, undefined, query); +export const getTenantPath = (query: TenantQuery, options?: CreateHrefOptions) => { + return createHref(routes.tenant, undefined, query, options); }; diff --git a/src/routes.ts b/src/routes.ts index b28707d18..acd87cf1c 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -5,7 +5,8 @@ import qs from 'qs'; import type {QueryParamConfig} from 'use-query-params'; import {StringParam} from 'use-query-params'; -import {backend, clusterName, webVersion} from './store'; +import {backend, basename, clusterName, webVersion} from './store'; +import {normalizePathSlashes} from './utils'; export const CLUSTERS = 'clusters'; export const CLUSTER = 'cluster'; @@ -55,10 +56,15 @@ const prepareRoute = (route: string) => { type Query = AnyRecord; +export interface CreateHrefOptions { + withBasename?: boolean; +} + export function createHref( route: string, params?: Record, query: Query = {}, + options: CreateHrefOptions = {}, ) { let extendedQuery = query; @@ -78,7 +84,14 @@ export function createHref( const preparedRoute = prepareRoute(route); - return `${compile(preparedRoute)(params)}${search}`; + const compiledRoute = `${compile(preparedRoute)(params)}${search}`; + + if (options.withBasename) { + // For SPA links react-router adds basename itself + // It is needed for external links - or uikit + return normalizePathSlashes(`${basename}/${compiledRoute}`); + } + return compiledRoute; } // embedded version could be located in some folder (e.g. host/some_folder/app_router_path) diff --git a/src/store/__test__/getUrlData.test.ts b/src/store/__test__/getUrlData.test.ts new file mode 100644 index 000000000..39f4506b1 --- /dev/null +++ b/src/store/__test__/getUrlData.test.ts @@ -0,0 +1,116 @@ +import {getUrlData} from '../getUrlData'; + +describe('getUrlData', () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + + afterEach(() => { + windowSpy.mockClear(); + }); + afterAll(() => { + windowSpy.mockRestore(); + }); + + describe('multi-cluster version', () => { + test('should parse pathname with folder', () => { + windowSpy.mockImplementation(() => { + return { + location: { + href: 'http://ydb-ui/ui/cluster?clusterName=my_cluster&backend=http://my-node:8765', + pathname: '/ui/cluster?clusterName=my_cluster&backend=http://my-node:8765', + }, + } as Window & typeof globalThis; + }); + const result = getUrlData({singleClusterMode: false, customBackend: undefined}); + expect(result).toEqual({ + basename: '/ui', + backend: 'http://my-node:8765', + clusterName: 'my_cluster', + }); + }); + test('should parse pathname with folder and some prefix', () => { + windowSpy.mockImplementation(() => { + return { + location: { + href: 'http://ydb-ui/monitoring/ui/cluster?clusterName=my_cluster&backend=http://my-node:8765', + pathname: + '/monitoring/ui/cluster?clusterName=my_cluster&backend=http://my-node:8765', + }, + } as Window & typeof globalThis; + }); + const result = getUrlData({singleClusterMode: false, customBackend: undefined}); + expect(result).toEqual({ + basename: '/monitoring/ui', + backend: 'http://my-node:8765', + clusterName: 'my_cluster', + }); + }); + test('should parse pathname with folder and some prefix', () => { + windowSpy.mockImplementation(() => { + return { + location: { + href: 'http://ydb-ui/cluster?clusterName=my_cluster&backend=http://my-node:8765', + pathname: '/cluster?clusterName=my_cluster&backend=http://my-node:8765', + }, + } as Window & typeof globalThis; + }); + const result = getUrlData({singleClusterMode: false, customBackend: undefined}); + expect(result).toEqual({ + basename: '', + backend: 'http://my-node:8765', + clusterName: 'my_cluster', + }); + }); + }); + describe('single-cluster version with custom backend', () => { + test('should parse correclty parse pathname', () => { + windowSpy.mockImplementation(() => { + return { + location: { + href: 'http://localhost:3000/cluster', + pathname: '/cluster', + }, + } as Window & typeof globalThis; + }); + const result = getUrlData({ + singleClusterMode: true, + customBackend: 'http://my-node:8765', + }); + expect(result).toEqual({ + basename: '', + backend: 'http://my-node:8765', + }); + }); + }); + describe('single-cluster embedded version', () => { + test('should parse pathname with folder', () => { + windowSpy.mockImplementation(() => { + return { + location: { + href: 'http://my-node:8765/monitoring/cluster', + pathname: '/monitoring/cluster', + }, + } as Window & typeof globalThis; + }); + const result = getUrlData({singleClusterMode: true, customBackend: undefined}); + expect(result).toEqual({ + basename: '/monitoring', + backend: '', + }); + }); + test('should parse pathname with folder and some prefix', () => { + windowSpy.mockImplementation(() => { + return { + location: { + href: 'http://my-node:8765/node/12/monitoring/cluster', + pathname: '/node/12/monitoring/cluster', + }, + } as Window & typeof globalThis; + }); + const result = getUrlData({singleClusterMode: true, customBackend: undefined}); + expect(result).toEqual({ + basename: '/node/12/monitoring', + backend: '/node/12', + }); + }); + }); +}); diff --git a/src/store/configureStore.ts b/src/store/configureStore.ts index f6774ab79..1be4ac711 100644 --- a/src/store/configureStore.ts +++ b/src/store/configureStore.ts @@ -60,7 +60,6 @@ export function configureStore({ api = new YdbEmbeddedAPI({webVersion, withCredentials: !customBackend}), } = {}) { ({backend, basename, clusterName} = getUrlData({ - href: window.location.href, singleClusterMode, customBackend, })); diff --git a/src/store/getUrlData.ts b/src/store/getUrlData.ts index a9573ac6f..fa704f6cf 100644 --- a/src/store/getUrlData.ts +++ b/src/store/getUrlData.ts @@ -1,33 +1,50 @@ +import {normalizePathSlashes} from '../utils'; + export const getUrlData = ({ - href, singleClusterMode, customBackend, }: { - href: string; singleClusterMode: boolean; customBackend?: string; }) => { + // UI could be located in "monitoring" or "ui" folders + // my-host:8765/some/path/monitoring/react-router-path or my-host:8765/some/path/ui/react-router-path + const parsedPrefix = window.location.pathname.match(/.*(?=\/(monitoring|ui)\/)/) || []; + const basenamePrefix = parsedPrefix.length > 0 ? parsedPrefix[0] : ''; + const folder = parsedPrefix.length > 1 ? parsedPrefix[1] : ''; + + let basename = ''; + + if (folder && !basenamePrefix) { + basename = normalizePathSlashes(`/${folder}`); + } else if (folder && basenamePrefix) { + basename = normalizePathSlashes(`${basenamePrefix}/${folder}`); + } + + const urlSearchParams = new URL(window.location.href).searchParams; + const backend = urlSearchParams.get('backend') ?? undefined; + const clusterName = urlSearchParams.get('clusterName') ?? undefined; + if (!singleClusterMode) { - const urlSearchParams = new URL(href).searchParams; - const backend = urlSearchParams.get('backend') ?? undefined; - const clusterName = urlSearchParams.get('clusterName') ?? undefined; + // Multi-cluster version + // Cluster and backend are determined by url params return { - basename: '/', + basename, backend, clusterName, }; } else if (customBackend) { - const urlSearchParams = new URL(href).searchParams; - const backend = urlSearchParams.get('backend') ?? undefined; + // Single-cluster version + // UI and backend are on different hosts + // There is a backend url param for requests return { - basename: '/', + basename, backend: backend ? backend : customBackend, }; } else { - const parsedPrefix = window.location.pathname.match(/.*(?=\/monitoring)/) || []; - const basenamePrefix = parsedPrefix.length > 0 ? parsedPrefix[0] : ''; - const basename = [basenamePrefix, 'monitoring'].filter(Boolean).join('/'); - + // Single-cluster version + // UI and backend are located on the same host + // We use the the host for backend requests return { basename, backend: basenamePrefix || '', diff --git a/src/utils/__test__/index.test.ts b/src/utils/__test__/index.test.ts new file mode 100644 index 000000000..7fb6a0ce4 --- /dev/null +++ b/src/utils/__test__/index.test.ts @@ -0,0 +1,34 @@ +import {normalizePathSlashes} from '../'; + +describe('normalizePathSlashes', () => { + test('should handle empty strings', () => { + expect(normalizePathSlashes('')).toBe(''); + }); + test('should handle strings without slashes', () => { + expect(normalizePathSlashes('path')).toBe('path'); + }); + test('should handle paths with single slash', () => { + expect(normalizePathSlashes('/path')).toBe('/path'); + }); + test('should handle paths with trailing slash', () => { + expect(normalizePathSlashes('path/')).toBe('path/'); + }); + test('should handle paths with multiple trailing slashes', () => { + expect(normalizePathSlashes('path////')).toBe('path/'); + }); + test('should handle full paths with normal slashes', () => { + expect(normalizePathSlashes('http://example.com/path/to/resource')).toBe( + 'http://example.com/path/to/resource', + ); + }); + test('should replace multiple slashes with a single slash', () => { + expect(normalizePathSlashes('http://example.com//path//to////resource')).toBe( + 'http://example.com/path/to/resource', + ); + }); + test('should replace slashes more than two slashes after a colon', () => { + expect(normalizePathSlashes('http://///example.com/path/to/resource')).toBe( + 'http://example.com/path/to/resource', + ); + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index 8957aa474..12eb24a54 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,3 +11,8 @@ export async function wait(time: number, value?: T): Promise resolve(value), time); }); } + +export function normalizePathSlashes(path: string) { + // Prevent multiple slashes when concatenating path parts + return path.replaceAll(/([^:])(\/\/+)/g, '$1/'); +} diff --git a/src/utils/parseBalancer.ts b/src/utils/parseBalancer.ts index 2008fba9c..37d3e6d71 100644 --- a/src/utils/parseBalancer.ts +++ b/src/utils/parseBalancer.ts @@ -1,3 +1,5 @@ +import {normalizePathSlashes} from '.'; + const protocolRegex = /^http[s]?:\/\//; const viewerPathnameRegex = /\/viewer\/json$/; @@ -63,9 +65,7 @@ export function prepareBackendFromBalancer(rawBalancer: string) { // Use meta_backend if it is defined to form backend url if (window.meta_backend) { - const path = window.meta_backend + '/' + preparedBalancer; - // Prevent multiple slashes in case meta_backend ends with slash or balancer starts with slash - return path.replaceAll(/([^:])(\/\/+)/g, '$1/'); + return normalizePathSlashes(`${window.meta_backend}/${preparedBalancer}`); } return preparedBalancer;