Skip to content

Commit 91963b9

Browse files
feat: enable basename for multi cluster version (#2153)
1 parent b82e029 commit 91963b9

File tree

12 files changed

+223
-29
lines changed

12 files changed

+223
-29
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"build": "rm -rf build && DISABLE_ESLINT_PLUGIN=true CI=true react-app-rewired build",
7070
"//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'",
7171
"build:embedded": "GENERATE_SOURCEMAP=false PUBLIC_URL=. REACT_APP_BACKEND=http://localhost:8765 REACT_APP_META_BACKEND=undefined npm run build",
72+
"build:embedded-mc": "GENERATE_SOURCEMAP=false PUBLIC_URL=. REACT_APP_BACKEND= REACT_APP_META_BACKEND= npm run build",
7273
"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",
7374
"lint": "run-p lint:*",
7475
"lint:js": "eslint --ext .js,.jsx,.ts,.tsx .",

src/components/TenantNameWrapper/TenantNameWrapper.tsx

+7-4
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,13 @@ export function TenantNameWrapper({tenant, additionalTenantsProps}: TenantNameWr
6767
status={tenant.Overall}
6868
infoPopoverContent={infoPopoverContent}
6969
hasClipboardButton
70-
path={getTenantPath({
71-
database: tenant.Name,
72-
backend,
73-
})}
70+
path={getTenantPath(
71+
{
72+
database: tenant.Name,
73+
backend,
74+
},
75+
{withBasename: isExternalLink},
76+
)}
7477
/>
7578
);
7679
}

src/containers/Cluster/utils.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type {CreateHrefOptions} from '../../routes';
12
import routes, {createHref} from '../../routes';
23
import type {ValueOf} from '../../types/common';
34

@@ -44,6 +45,6 @@ export function isClusterTab(tab: any): tab is ClusterTab {
4445
return Object.values(clusterTabsIds).includes(tab);
4546
}
4647

47-
export const getClusterPath = (activeTab?: ClusterTab, query = {}) => {
48-
return createHref(routes.cluster, activeTab ? {activeTab} : undefined, query);
48+
export const getClusterPath = (activeTab?: ClusterTab, query = {}, options?: CreateHrefOptions) => {
49+
return createHref(routes.cluster, activeTab ? {activeTab} : undefined, query, options);
4950
};

src/containers/Clusters/columns.tsx

+6-2
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const CLUSTERS_COLUMNS: Column<PreparedCluster>[] = [
3636
const clusterPath =
3737
useEmbeddedUi && backend
3838
? createDeveloperUIMonitoringPageHref(backend)
39-
: getClusterPath(undefined, {backend, clusterName});
39+
: getClusterPath(undefined, {backend, clusterName}, {withBasename: true});
4040

4141
const clusterStatus = row.cluster?.Overall;
4242

@@ -110,7 +110,11 @@ export const CLUSTERS_COLUMNS: Column<PreparedCluster>[] = [
110110
preparedVersions.length > 0 && (
111111
<ExternalLink
112112
className={b('cluster-versions')}
113-
href={getClusterPath(clusterTabsIds.versions, {backend, clusterName})}
113+
href={getClusterPath(
114+
clusterTabsIds.versions,
115+
{backend, clusterName},
116+
{withBasename: true},
117+
)}
114118
>
115119
<React.Fragment>
116120
{preparedVersions.map((item, index) => (

src/containers/Tenant/TenantPages.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type {CreateHrefOptions} from '../../routes';
12
import routes, {createHref} from '../../routes';
23
import {TENANT_SUMMARY_TABS_IDS} from '../../store/reducers/tenant/constants';
34
import type {paramSetup} from '../../store/state-url-mapping';
@@ -40,6 +41,6 @@ export const TENANT_SCHEMA_TAB = [
4041
},
4142
];
4243

43-
export const getTenantPath = (query: TenantQuery) => {
44-
return createHref(routes.tenant, undefined, query);
44+
export const getTenantPath = (query: TenantQuery, options?: CreateHrefOptions) => {
45+
return createHref(routes.tenant, undefined, query, options);
4546
};

src/routes.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import qs from 'qs';
55
import type {QueryParamConfig} from 'use-query-params';
66
import {StringParam} from 'use-query-params';
77

8-
import {backend, clusterName, webVersion} from './store';
8+
import {backend, basename, clusterName, webVersion} from './store';
9+
import {normalizePathSlashes} from './utils';
910

1011
export const CLUSTERS = 'clusters';
1112
export const CLUSTER = 'cluster';
@@ -55,10 +56,15 @@ const prepareRoute = (route: string) => {
5556

5657
type Query = AnyRecord;
5758

59+
export interface CreateHrefOptions {
60+
withBasename?: boolean;
61+
}
62+
5863
export function createHref(
5964
route: string,
6065
params?: Record<string, string | number | undefined>,
6166
query: Query = {},
67+
options: CreateHrefOptions = {},
6268
) {
6369
let extendedQuery = query;
6470

@@ -78,7 +84,14 @@ export function createHref(
7884

7985
const preparedRoute = prepareRoute(route);
8086

81-
return `${compile(preparedRoute)(params)}${search}`;
87+
const compiledRoute = `${compile(preparedRoute)(params)}${search}`;
88+
89+
if (options.withBasename) {
90+
// For SPA links react-router adds basename itself
91+
// It is needed for external links - <a> or uikit <Link>
92+
return normalizePathSlashes(`${basename}/${compiledRoute}`);
93+
}
94+
return compiledRoute;
8295
}
8396

8497
// embedded version could be located in some folder (e.g. host/some_folder/app_router_path)

src/store/__test__/getUrlData.test.ts

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {getUrlData} from '../getUrlData';
2+
3+
describe('getUrlData', () => {
4+
const windowSpy = jest.spyOn(window, 'window', 'get');
5+
6+
afterEach(() => {
7+
windowSpy.mockClear();
8+
});
9+
afterAll(() => {
10+
windowSpy.mockRestore();
11+
});
12+
13+
describe('multi-cluster version', () => {
14+
test('should parse pathname with folder', () => {
15+
windowSpy.mockImplementation(() => {
16+
return {
17+
location: {
18+
href: 'http://ydb-ui/ui/cluster?clusterName=my_cluster&backend=http://my-node:8765',
19+
pathname: '/ui/cluster?clusterName=my_cluster&backend=http://my-node:8765',
20+
},
21+
} as Window & typeof globalThis;
22+
});
23+
const result = getUrlData({singleClusterMode: false, customBackend: undefined});
24+
expect(result).toEqual({
25+
basename: '/ui',
26+
backend: 'http://my-node:8765',
27+
clusterName: 'my_cluster',
28+
});
29+
});
30+
test('should parse pathname with folder and some prefix', () => {
31+
windowSpy.mockImplementation(() => {
32+
return {
33+
location: {
34+
href: 'http://ydb-ui/monitoring/ui/cluster?clusterName=my_cluster&backend=http://my-node:8765',
35+
pathname:
36+
'/monitoring/ui/cluster?clusterName=my_cluster&backend=http://my-node:8765',
37+
},
38+
} as Window & typeof globalThis;
39+
});
40+
const result = getUrlData({singleClusterMode: false, customBackend: undefined});
41+
expect(result).toEqual({
42+
basename: '/monitoring/ui',
43+
backend: 'http://my-node:8765',
44+
clusterName: 'my_cluster',
45+
});
46+
});
47+
test('should parse pathname with folder and some prefix', () => {
48+
windowSpy.mockImplementation(() => {
49+
return {
50+
location: {
51+
href: 'http://ydb-ui/cluster?clusterName=my_cluster&backend=http://my-node:8765',
52+
pathname: '/cluster?clusterName=my_cluster&backend=http://my-node:8765',
53+
},
54+
} as Window & typeof globalThis;
55+
});
56+
const result = getUrlData({singleClusterMode: false, customBackend: undefined});
57+
expect(result).toEqual({
58+
basename: '',
59+
backend: 'http://my-node:8765',
60+
clusterName: 'my_cluster',
61+
});
62+
});
63+
});
64+
describe('single-cluster version with custom backend', () => {
65+
test('should parse correclty parse pathname', () => {
66+
windowSpy.mockImplementation(() => {
67+
return {
68+
location: {
69+
href: 'http://localhost:3000/cluster',
70+
pathname: '/cluster',
71+
},
72+
} as Window & typeof globalThis;
73+
});
74+
const result = getUrlData({
75+
singleClusterMode: true,
76+
customBackend: 'http://my-node:8765',
77+
});
78+
expect(result).toEqual({
79+
basename: '',
80+
backend: 'http://my-node:8765',
81+
});
82+
});
83+
});
84+
describe('single-cluster embedded version', () => {
85+
test('should parse pathname with folder', () => {
86+
windowSpy.mockImplementation(() => {
87+
return {
88+
location: {
89+
href: 'http://my-node:8765/monitoring/cluster',
90+
pathname: '/monitoring/cluster',
91+
},
92+
} as Window & typeof globalThis;
93+
});
94+
const result = getUrlData({singleClusterMode: true, customBackend: undefined});
95+
expect(result).toEqual({
96+
basename: '/monitoring',
97+
backend: '',
98+
});
99+
});
100+
test('should parse pathname with folder and some prefix', () => {
101+
windowSpy.mockImplementation(() => {
102+
return {
103+
location: {
104+
href: 'http://my-node:8765/node/12/monitoring/cluster',
105+
pathname: '/node/12/monitoring/cluster',
106+
},
107+
} as Window & typeof globalThis;
108+
});
109+
const result = getUrlData({singleClusterMode: true, customBackend: undefined});
110+
expect(result).toEqual({
111+
basename: '/node/12/monitoring',
112+
backend: '/node/12',
113+
});
114+
});
115+
});
116+
});

src/store/configureStore.ts

-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ export function configureStore({
6060
api = new YdbEmbeddedAPI({webVersion, withCredentials: !customBackend}),
6161
} = {}) {
6262
({backend, basename, clusterName} = getUrlData({
63-
href: window.location.href,
6463
singleClusterMode,
6564
customBackend,
6665
}));

src/store/getUrlData.ts

+30-13
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,50 @@
1+
import {normalizePathSlashes} from '../utils';
2+
13
export const getUrlData = ({
2-
href,
34
singleClusterMode,
45
customBackend,
56
}: {
6-
href: string;
77
singleClusterMode: boolean;
88
customBackend?: string;
99
}) => {
10+
// UI could be located in "monitoring" or "ui" folders
11+
// my-host:8765/some/path/monitoring/react-router-path or my-host:8765/some/path/ui/react-router-path
12+
const parsedPrefix = window.location.pathname.match(/.*(?=\/(monitoring|ui)\/)/) || [];
13+
const basenamePrefix = parsedPrefix.length > 0 ? parsedPrefix[0] : '';
14+
const folder = parsedPrefix.length > 1 ? parsedPrefix[1] : '';
15+
16+
let basename = '';
17+
18+
if (folder && !basenamePrefix) {
19+
basename = normalizePathSlashes(`/${folder}`);
20+
} else if (folder && basenamePrefix) {
21+
basename = normalizePathSlashes(`${basenamePrefix}/${folder}`);
22+
}
23+
24+
const urlSearchParams = new URL(window.location.href).searchParams;
25+
const backend = urlSearchParams.get('backend') ?? undefined;
26+
const clusterName = urlSearchParams.get('clusterName') ?? undefined;
27+
1028
if (!singleClusterMode) {
11-
const urlSearchParams = new URL(href).searchParams;
12-
const backend = urlSearchParams.get('backend') ?? undefined;
13-
const clusterName = urlSearchParams.get('clusterName') ?? undefined;
29+
// Multi-cluster version
30+
// Cluster and backend are determined by url params
1431
return {
15-
basename: '/',
32+
basename,
1633
backend,
1734
clusterName,
1835
};
1936
} else if (customBackend) {
20-
const urlSearchParams = new URL(href).searchParams;
21-
const backend = urlSearchParams.get('backend') ?? undefined;
37+
// Single-cluster version
38+
// UI and backend are on different hosts
39+
// There is a backend url param for requests
2240
return {
23-
basename: '/',
41+
basename,
2442
backend: backend ? backend : customBackend,
2543
};
2644
} else {
27-
const parsedPrefix = window.location.pathname.match(/.*(?=\/monitoring)/) || [];
28-
const basenamePrefix = parsedPrefix.length > 0 ? parsedPrefix[0] : '';
29-
const basename = [basenamePrefix, 'monitoring'].filter(Boolean).join('/');
30-
45+
// Single-cluster version
46+
// UI and backend are located on the same host
47+
// We use the the host for backend requests
3148
return {
3249
basename,
3350
backend: basenamePrefix || '',

src/utils/__test__/index.test.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {normalizePathSlashes} from '../';
2+
3+
describe('normalizePathSlashes', () => {
4+
test('should handle empty strings', () => {
5+
expect(normalizePathSlashes('')).toBe('');
6+
});
7+
test('should handle strings without slashes', () => {
8+
expect(normalizePathSlashes('path')).toBe('path');
9+
});
10+
test('should handle paths with single slash', () => {
11+
expect(normalizePathSlashes('/path')).toBe('/path');
12+
});
13+
test('should handle paths with trailing slash', () => {
14+
expect(normalizePathSlashes('path/')).toBe('path/');
15+
});
16+
test('should handle paths with multiple trailing slashes', () => {
17+
expect(normalizePathSlashes('path////')).toBe('path/');
18+
});
19+
test('should handle full paths with normal slashes', () => {
20+
expect(normalizePathSlashes('http://example.com/path/to/resource')).toBe(
21+
'http://example.com/path/to/resource',
22+
);
23+
});
24+
test('should replace multiple slashes with a single slash', () => {
25+
expect(normalizePathSlashes('http://example.com//path//to////resource')).toBe(
26+
'http://example.com/path/to/resource',
27+
);
28+
});
29+
test('should replace slashes more than two slashes after a colon', () => {
30+
expect(normalizePathSlashes('http://///example.com/path/to/resource')).toBe(
31+
'http://example.com/path/to/resource',
32+
);
33+
});
34+
});

src/utils/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,8 @@ export async function wait<T = unknown>(time: number, value?: T): Promise<T | un
1111
setTimeout(() => resolve(value), time);
1212
});
1313
}
14+
15+
export function normalizePathSlashes(path: string) {
16+
// Prevent multiple slashes when concatenating path parts
17+
return path.replaceAll(/([^:])(\/\/+)/g, '$1/');
18+
}

src/utils/parseBalancer.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import {normalizePathSlashes} from '.';
2+
13
const protocolRegex = /^http[s]?:\/\//;
24
const viewerPathnameRegex = /\/viewer\/json$/;
35

@@ -63,9 +65,7 @@ export function prepareBackendFromBalancer(rawBalancer: string) {
6365

6466
// Use meta_backend if it is defined to form backend url
6567
if (window.meta_backend) {
66-
const path = window.meta_backend + '/' + preparedBalancer;
67-
// Prevent multiple slashes in case meta_backend ends with slash or balancer starts with slash
68-
return path.replaceAll(/([^:])(\/\/+)/g, '$1/');
68+
return normalizePathSlashes(`${window.meta_backend}/${preparedBalancer}`);
6969
}
7070

7171
return preparedBalancer;

0 commit comments

Comments
 (0)