Skip to content

feat: enable basename for multi cluster version #2153

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 18, 2025
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Member Author

Choose a reason for hiding this comment

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

Build embedded multi-cluster version - app host is used as meta backend

"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 .",
Expand Down
11 changes: 7 additions & 4 deletions src/components/TenantNameWrapper/TenantNameWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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},
)}
/>
);
}
5 changes: 3 additions & 2 deletions src/containers/Cluster/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type {CreateHrefOptions} from '../../routes';
import routes, {createHref} from '../../routes';
import type {ValueOf} from '../../types/common';

Expand Down Expand Up @@ -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);
};
8 changes: 6 additions & 2 deletions src/containers/Clusters/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const CLUSTERS_COLUMNS: Column<PreparedCluster>[] = [
const clusterPath =
useEmbeddedUi && backend
? createDeveloperUIMonitoringPageHref(backend)
: getClusterPath(undefined, {backend, clusterName});
: getClusterPath(undefined, {backend, clusterName}, {withBasename: true});

const clusterStatus = row.cluster?.Overall;

Expand Down Expand Up @@ -110,7 +110,11 @@ export const CLUSTERS_COLUMNS: Column<PreparedCluster>[] = [
preparedVersions.length > 0 && (
<ExternalLink
className={b('cluster-versions')}
href={getClusterPath(clusterTabsIds.versions, {backend, clusterName})}
href={getClusterPath(
clusterTabsIds.versions,
{backend, clusterName},
{withBasename: true},
)}
>
<React.Fragment>
{preparedVersions.map((item, index) => (
Expand Down
5 changes: 3 additions & 2 deletions src/containers/Tenant/TenantPages.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
};
17 changes: 15 additions & 2 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -55,10 +56,15 @@ const prepareRoute = (route: string) => {

type Query = AnyRecord;

export interface CreateHrefOptions {
withBasename?: boolean;
}

export function createHref(
route: string,
params?: Record<string, string | number | undefined>,
query: Query = {},
options: CreateHrefOptions = {},
) {
let extendedQuery = query;

Expand All @@ -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 - <a> or uikit <Link>
return normalizePathSlashes(`${basename}/${compiledRoute}`);
}
return compiledRoute;
}

// embedded version could be located in some folder (e.g. host/some_folder/app_router_path)
Expand Down
116 changes: 116 additions & 0 deletions src/store/__test__/getUrlData.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
});
1 change: 0 additions & 1 deletion src/store/configureStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ export function configureStore({
api = new YdbEmbeddedAPI({webVersion, withCredentials: !customBackend}),
} = {}) {
({backend, basename, clusterName} = getUrlData({
href: window.location.href,
singleClusterMode,
customBackend,
}));
Expand Down
43 changes: 30 additions & 13 deletions src/store/getUrlData.ts
Original file line number Diff line number Diff line change
@@ -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 || '',
Expand Down
34 changes: 34 additions & 0 deletions src/utils/__test__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
5 changes: 5 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ export async function wait<T = unknown>(time: number, value?: T): Promise<T | un
setTimeout(() => resolve(value), time);
});
}

export function normalizePathSlashes(path: string) {
// Prevent multiple slashes when concatenating path parts
return path.replaceAll(/([^:])(\/\/+)/g, '$1/');
}
6 changes: 3 additions & 3 deletions src/utils/parseBalancer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {normalizePathSlashes} from '.';

const protocolRegex = /^http[s]?:\/\//;
const viewerPathnameRegex = /\/viewer\/json$/;

Expand Down Expand Up @@ -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;
Expand Down
Loading