Skip to content

Commit 3a47390

Browse files
Raubzeuggreptile-apps[bot]Copilot
authored
feat(uiFactory): support developerUiFirstPathSegment (#3780)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent a393e98 commit 3a47390

3 files changed

Lines changed: 254 additions & 12 deletions

File tree

src/uiFactory/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ export interface UIFactory<H extends string = CommonIssueType, T extends string
9393
hideNewFeaturesNotifications?: {
9494
navigationV2?: boolean;
9595
};
96+
97+
/**
98+
* First URL path segment for Developer UI routes.
99+
* Must be a single path segment without leading/trailing slashes, for example `devui`.
100+
* Do not pass `/devui/` or nested paths like `devui/node`.
101+
*/
102+
developerUiFirstPathSegment?: string;
96103
}
97104

98105
export type HandleCreateDB = (params: {clusterName: string}) => Promise<boolean>;

src/utils/developerUI/__test__/developerUI.test.ts

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
import type {YdbEmbeddedAPI} from '../../../services/api';
2+
import {configureUIFactory} from '../../../uiFactory/uiFactory';
23
import {
34
createDeveloperUIInternalPageHref,
45
createDeveloperUILinkWithNodeId,
6+
createDeveloperUIMonitoringPageHref,
57
createPDiskDeveloperUILink,
8+
createTabletDeveloperUIHref,
69
createVDiskDeveloperUILink,
710
} from '../developerUI';
811

912
describe('Developer UI links generators', () => {
1013
beforeAll(() => {
1114
const api = {
1215
viewer: {
13-
getPath: () => '',
16+
getPath: jest.fn(() => ''),
1417
},
1518
};
1619
window.api = api as unknown as YdbEmbeddedAPI;
1720
});
1821

22+
beforeEach(() => {
23+
configureUIFactory({developerUiFirstPathSegment: undefined});
24+
jest.mocked(window.api.viewer.getPath).mockReturnValue('');
25+
});
26+
1927
describe('createDeveloperUIInternalPageHref', () => {
2028
test('should create correct link for embedded UI', () => {
2129
expect(createDeveloperUIInternalPageHref('')).toBe('/internal');
@@ -48,6 +56,16 @@ describe('Developer UI links generators', () => {
4856
test('should create relative link with no host', () => {
4957
expect(createDeveloperUILinkWithNodeId(1)).toBe('/node/1');
5058
});
59+
test('should strip existing node path from current host when no firstSegment', () => {
60+
jest.mocked(window.api.viewer.getPath).mockReturnValue('/node/3');
61+
expect(createDeveloperUILinkWithNodeId(1)).toBe('/node/1');
62+
});
63+
test('should strip existing node path from current absolute host when no firstSegment', () => {
64+
jest.mocked(window.api.viewer.getPath).mockReturnValue(
65+
'http://my-ydb-host.net:8765/node/3',
66+
);
67+
expect(createDeveloperUILinkWithNodeId(1)).toBe('http://my-ydb-host.net:8765/node/1');
68+
});
5169
test('should create relative link with existing relative path with nodeId', () => {
5270
expect(createDeveloperUILinkWithNodeId(1, '/node/3/')).toBe('/node/1');
5371
});
@@ -86,4 +104,193 @@ describe('Developer UI links generators', () => {
86104
).toBe('/node/1/actors/vdisks/vdisk000000001_000000001');
87105
});
88106
});
107+
108+
describe('createDeveloperUIMonitoringPageHref', () => {
109+
test('should create correct link for embedded UI', () => {
110+
expect(createDeveloperUIMonitoringPageHref('')).toBe('/monitoring');
111+
});
112+
test('should create correct link with custom host', () => {
113+
expect(createDeveloperUIMonitoringPageHref('http://my-ydb-host.net:8765')).toBe(
114+
'http://my-ydb-host.net:8765/monitoring',
115+
);
116+
});
117+
});
118+
119+
describe('createTabletDeveloperUIHref', () => {
120+
test('should create correct link with tabletId', () => {
121+
expect(createTabletDeveloperUIHref(123, undefined, 'TabletID', '')).toBe(
122+
'/tablets?TabletID=123',
123+
);
124+
});
125+
test('should create correct link with tabletId and page', () => {
126+
expect(createTabletDeveloperUIHref(123, 'counters', 'TabletID', '')).toBe(
127+
'/tablets/counters?TabletID=123',
128+
);
129+
});
130+
test('should create correct link with custom host', () => {
131+
expect(
132+
createTabletDeveloperUIHref(
133+
123,
134+
undefined,
135+
'TabletID',
136+
'http://my-ydb-host.net:8765',
137+
),
138+
).toBe('http://my-ydb-host.net:8765/tablets?TabletID=123');
139+
});
140+
});
141+
142+
describe('developerUiFirstPathSegment', () => {
143+
describe('createDeveloperUIInternalPageHref', () => {
144+
test('should insert first segment when configured', () => {
145+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
146+
expect(createDeveloperUIInternalPageHref()).toBe('/custom/internal');
147+
});
148+
149+
test('should keep explicit custom host unchanged', () => {
150+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
151+
expect(createDeveloperUIInternalPageHref('http://my-ydb-host.net:8765')).toBe(
152+
'http://my-ydb-host.net:8765/internal',
153+
);
154+
});
155+
156+
test('should keep explicit custom host with existing path unchanged', () => {
157+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
158+
expect(
159+
createDeveloperUIInternalPageHref('http://my-ydb-host.net:8765/node/5'),
160+
).toBe('http://my-ydb-host.net:8765/node/5/internal');
161+
});
162+
163+
test('should keep explicit proxy path unchanged', () => {
164+
configureUIFactory({developerUiFirstPathSegment: 'api'});
165+
expect(createDeveloperUIInternalPageHref('/my-ydb-host.net:8765')).toBe(
166+
'/my-ydb-host.net:8765/internal',
167+
);
168+
});
169+
170+
test('should append first segment after existing relative path from current host', () => {
171+
configureUIFactory({developerUiFirstPathSegment: 'api'});
172+
jest.mocked(window.api.viewer.getPath).mockReturnValue('/node/5');
173+
expect(createDeveloperUIInternalPageHref()).toBe('/node/5/api/internal');
174+
});
175+
176+
test('should append first segment after existing absolute path from current host', () => {
177+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
178+
jest.mocked(window.api.viewer.getPath).mockReturnValue(
179+
'http://my-ydb-host.net:8765/node/5',
180+
);
181+
expect(createDeveloperUIInternalPageHref()).toBe(
182+
'http://my-ydb-host.net:8765/node/5/custom/internal',
183+
);
184+
});
185+
186+
test('should append first segment after proxy cluster path from current host', () => {
187+
configureUIFactory({developerUiFirstPathSegment: 'devui'});
188+
jest.mocked(window.api.viewer.getPath).mockReturnValue(
189+
'/api/meta3/proxy/cluster/ru-central1',
190+
);
191+
expect(createDeveloperUIInternalPageHref()).toBe(
192+
'/api/meta3/proxy/cluster/ru-central1/devui/internal',
193+
);
194+
});
195+
});
196+
197+
describe('createDeveloperUIMonitoringPageHref', () => {
198+
test('should insert first segment when configured', () => {
199+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
200+
expect(createDeveloperUIMonitoringPageHref()).toBe('/custom/monitoring');
201+
});
202+
203+
test('should keep explicit custom host unchanged', () => {
204+
configureUIFactory({developerUiFirstPathSegment: 'api'});
205+
expect(createDeveloperUIMonitoringPageHref('http://my-ydb-host.net:8765')).toBe(
206+
'http://my-ydb-host.net:8765/monitoring',
207+
);
208+
});
209+
});
210+
211+
describe('createDeveloperUILinkWithNodeId', () => {
212+
test('should insert first segment when configured', () => {
213+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
214+
expect(createDeveloperUILinkWithNodeId(1)).toBe('/custom/node/1');
215+
});
216+
217+
test('should keep explicit custom host unchanged', () => {
218+
configureUIFactory({developerUiFirstPathSegment: 'api'});
219+
expect(
220+
createDeveloperUILinkWithNodeId(
221+
1,
222+
'http://ydb-vla-dev02-001.search.yandex.net:8765',
223+
),
224+
).toBe('http://ydb-vla-dev02-001.search.yandex.net:8765/node/1');
225+
});
226+
227+
test('should replace nodeId in path with first segment', () => {
228+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
229+
expect(createDeveloperUILinkWithNodeId(1, '/custom/node/3')).toBe('/custom/node/1');
230+
});
231+
232+
test('should strip existing node path and append first segment before adding node path', () => {
233+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
234+
jest.mocked(window.api.viewer.getPath).mockReturnValue(
235+
'http://my-ydb-host.net:8765/node/3',
236+
);
237+
expect(createDeveloperUILinkWithNodeId(1)).toBe(
238+
'http://my-ydb-host.net:8765/custom/node/1',
239+
);
240+
});
241+
});
242+
243+
describe('createPDiskDeveloperUILink', () => {
244+
test('should insert first segment when configured', () => {
245+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
246+
expect(createPDiskDeveloperUILink({nodeId: 1, pDiskId: 1})).toBe(
247+
'/custom/node/1/actors/pdisks/pdisk000000001',
248+
);
249+
});
250+
});
251+
252+
describe('createVDiskDeveloperUILink', () => {
253+
test('should insert first segment when configured', () => {
254+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
255+
expect(
256+
createVDiskDeveloperUILink({
257+
nodeId: 1,
258+
pDiskId: 1,
259+
vDiskSlotId: 1,
260+
}),
261+
).toBe('/custom/node/1/actors/vdisks/vdisk000000001_000000001');
262+
});
263+
});
264+
265+
describe('createTabletDeveloperUIHref', () => {
266+
test('should insert first segment when configured', () => {
267+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
268+
expect(createTabletDeveloperUIHref(123)).toBe('/custom/tablets?TabletID=123');
269+
});
270+
271+
test('should insert first segment with tablet page', () => {
272+
configureUIFactory({developerUiFirstPathSegment: 'api'});
273+
expect(createTabletDeveloperUIHref(123, 'counters')).toBe(
274+
'/api/tablets/counters?TabletID=123',
275+
);
276+
});
277+
278+
test('should keep explicit custom host unchanged', () => {
279+
configureUIFactory({developerUiFirstPathSegment: 'custom'});
280+
expect(
281+
createTabletDeveloperUIHref(
282+
123,
283+
undefined,
284+
'TabletID',
285+
'http://my-ydb-host.net:8765',
286+
),
287+
).toBe('http://my-ydb-host.net:8765/tablets?TabletID=123');
288+
});
289+
290+
test('should normalize first segment slashes', () => {
291+
configureUIFactory({developerUiFirstPathSegment: '/custom/'});
292+
expect(createTabletDeveloperUIHref(123)).toBe('/custom/tablets?TabletID=123');
293+
});
294+
});
295+
});
89296
});

src/utils/developerUI/developerUI.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,35 @@ import {uiFactory} from '../../uiFactory/uiFactory';
22
import {useIsUserAllowedToMakeChanges} from '../hooks/useIsUserAllowedToMakeChanges';
33
import {pad9} from '../utils';
44

5+
function appendPathSegment(host: string, firstSegment: string) {
6+
const normalizedFirstSegment = firstSegment.replace(/^\/+|\/+$/g, '');
7+
const normalizedHost = host.replace(/\/+$/g, '');
8+
9+
if (!normalizedFirstSegment) {
10+
return normalizedHost || host;
11+
}
12+
13+
if (!normalizedHost) {
14+
return `/${normalizedFirstSegment}`;
15+
}
16+
17+
return `${normalizedHost}/${normalizedFirstSegment}`;
18+
}
19+
520
function getCurrentHost() {
621
// It always has correct backend
7-
return window.api.viewer.getPath('');
22+
const host = String(window.api.viewer.getPath(''));
23+
const firstSegment = uiFactory.developerUiFirstPathSegment;
24+
25+
if (!firstSegment) {
26+
return host;
27+
}
28+
29+
return appendPathSegment(host, firstSegment);
30+
}
31+
32+
function replaceNodePath(host: string, replacement = '') {
33+
return host.replace(/\/node\/\d+\/?$/, replacement);
834
}
935

1036
export function createDeveloperUIInternalPageHref(host = getCurrentHost()) {
@@ -16,19 +42,21 @@ export function createDeveloperUIMonitoringPageHref(host = getCurrentHost()) {
1642
}
1743

1844
// Current node connects with target node by itself using nodeId
19-
export const createDeveloperUILinkWithNodeId = (
20-
nodeId: number | string,
21-
host = getCurrentHost(),
22-
) => {
23-
const nodePathRegexp = /\/node\/\d+\/?$/g;
45+
export const createDeveloperUILinkWithNodeId = (nodeId: number | string, host?: string) => {
46+
const nodePath = `/node/${nodeId}`;
2447

25-
// In case current backend is already relative node path ({host}/node/{nodeId})
26-
// We replace existing nodeId path with new nodeId path
27-
if (nodePathRegexp.test(String(host))) {
28-
return String(host).replace(nodePathRegexp, `/node/${nodeId}`);
48+
if (host !== undefined) {
49+
return `${replaceNodePath(host)}${nodePath}`;
2950
}
3051

31-
return `${host ?? ''}/node/${nodeId}`;
52+
// When using current host, strip any existing /node/{id} before appending firstSegment
53+
const rawHost = window.api.viewer.getPath('');
54+
const baseHost = replaceNodePath(rawHost);
55+
const firstSegment = uiFactory.developerUiFirstPathSegment;
56+
57+
const hostWithSegment = firstSegment ? appendPathSegment(baseHost, firstSegment) : baseHost;
58+
59+
return `${hostWithSegment}${nodePath}`;
3260
};
3361

3462
interface PDiskDeveloperUILinkParams {

0 commit comments

Comments
 (0)