Skip to content

Commit

Permalink
frontend: Websocket backward compatibility
Browse files Browse the repository at this point in the history
This adds a new way to use new way to run websocket multiplexer. Default
way would be the legacy way which creates multiple websocket connection.
This adds a new flag `REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER` to run the
new API.

Signed-off-by: Kautilya Tripathi <[email protected]>
  • Loading branch information
knrt10 committed Dec 19, 2024
1 parent 4558c5c commit ab9c34b
Show file tree
Hide file tree
Showing 8 changed files with 1,143 additions and 262 deletions.
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
"build": "cross-env PUBLIC_URL=./ NODE_OPTIONS=--max-old-space-size=8096 vite build && npx shx rm -f build/frontend/index.baseUrl.html",
"pretest": "npm run make-version",
"test": "vitest",
"start-without-multiplexer": "cross-env REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER=false npm run start",
"lint": "eslint --cache -c package.json --ext .js,.ts,.tsx src/ ../app/electron ../plugins/headlamp-plugin --ignore-pattern ../plugins/headlamp-plugin/template --ignore-pattern ../plugins/headlamp-plugin/lib/",
"format": "prettier --config package.json --write --cache src ../app/electron ../app/tsconfig.json ../app/scripts ../plugins/headlamp-plugin/bin ../plugins/headlamp-plugin/config ../plugins/headlamp-plugin/template ../plugins/headlamp-plugin/test*.js ../plugins/headlamp-plugin/*.json ../plugins/headlamp-plugin/*.js",
"format-check": "prettier --config package.json --check --cache src ../app/electron ../app/tsconfig.json ../app/scripts ../plugins/headlamp-plugin/bin ../plugins/headlamp-plugin/config ../plugins/headlamp-plugin/template ../plugins/headlamp-plugin/test*.js ../plugins/headlamp-plugin/*.json ../plugins/headlamp-plugin/*.js",
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,14 @@ function loadTableSettings(tableId: string): { id: string; show: boolean }[] {
return settings;
}

/**
* @returns true if the websocket multiplexer is enabled.
* defaults to true. This is a feature flag to enable the websocket multiplexer.
*/
export function getWebsocketMultiplexerEnabled(): boolean {
return import.meta.env.REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER !== 'false';
}

/**
* The backend token to use when making API calls from Headlamp when running as an app.
* The app opens the index.html?backendToken=... and passes the token to the frontend
Expand Down Expand Up @@ -393,6 +401,7 @@ const exportFunctions = {
storeClusterSettings,
loadClusterSettings,
getHeadlampAPIHeaders,
getWebsocketMultiplexerEnabled,
storeTableSettings,
loadTableSettings,
};
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/lib/k8s/api/v2/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getCluster } from '../../../cluster';
import { ApiError, QueryParameters } from '../../apiProxy';
import { KubeObject, KubeObjectInterface } from '../../KubeObject';
import { clusterFetch } from './fetch';
import { KubeListUpdateEvent } from './KubeList';
import { KubeObjectEndpoint } from './KubeObjectEndpoint';
import { makeUrl } from './makeUrl';
import { useWebSocket } from './webSocket';
Expand Down Expand Up @@ -132,7 +133,7 @@ export function useKubeObject<K extends KubeObject>({

const data: Instance | null = query.error ? null : query.data ?? null;

useWebSocket<Instance>({
useWebSocket<KubeListUpdateEvent<K>>({
url: () =>
makeUrl([KubeObjectEndpoint.toUrl(endpoint!)], {
...cleanedUpQueryParams,
Expand All @@ -141,7 +142,7 @@ export function useKubeObject<K extends KubeObject>({
}),
enabled: !!endpoint && !!data,
cluster,
onMessage(update) {
onMessage(update: KubeListUpdateEvent<K>) {
if (update.type !== 'ADDED' && update.object) {
client.setQueryData(queryKey, new kubeObjectClass(update.object));
}
Expand Down
112 changes: 112 additions & 0 deletions frontend/src/lib/k8s/api/v2/useKubeObjectList.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import {
kubeObjectListQuery,
ListResponse,
Expand All @@ -8,6 +9,18 @@ import {
} from './useKubeObjectList';
import * as websocket from './webSocket';

// Mock WebSocket functionality
const mockUseWebSockets = vi.fn();
const mockSubscribe = vi.fn().mockImplementation(() => Promise.resolve(() => {}));

vi.mock('./webSocket', () => ({
useWebSockets: (...args: any[]) => mockUseWebSockets(...args),
WebSocketManager: {
subscribe: (...args: any[]) => mockSubscribe(...args),
},
BASE_WS_URL: 'http://localhost:3000',
}));

describe('makeListRequests', () => {
describe('for non namespaced resource', () => {
it('should not include namespace in requests', () => {
Expand Down Expand Up @@ -85,6 +98,11 @@ const mockClass = class {
} as any;

describe('useWatchKubeObjectLists', () => {
beforeEach(() => {
vi.stubEnv('REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER', 'false');
vi.clearAllMocks();
});

it('should not be enabled when no endpoint is provided', () => {
const spy = vi.spyOn(websocket, 'useWebSockets');
const queryClient = new QueryClient();
Expand Down Expand Up @@ -221,3 +239,97 @@ describe('useWatchKubeObjectLists', () => {
).toBe(objectB);
});
});

describe('useWatchKubeObjectLists (Multiplexer)', () => {
beforeEach(() => {
vi.stubEnv('REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER', 'true');
vi.clearAllMocks();
});

it('should subscribe using WebSocketManager when multiplexer is enabled', () => {
const lists = [{ cluster: 'cluster-a', namespace: 'namespace-a', resourceVersion: '1' }];

renderHook(
() =>
useWatchKubeObjectLists({
kubeObjectClass: mockClass,
endpoint: { version: 'v1', resource: 'pods' },
lists,
}),
{
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
),
}
);

expect(mockSubscribe).toHaveBeenCalledWith(
'cluster-a',
expect.stringContaining('/api/v1/namespaces/namespace-a/pods'),
'watch=1&resourceVersion=1',
expect.any(Function)
);
});

it('should subscribe to multiple clusters', () => {
const lists = [
{ cluster: 'cluster-a', namespace: 'namespace-a', resourceVersion: '1' },
{ cluster: 'cluster-b', namespace: 'namespace-b', resourceVersion: '2' },
];

renderHook(
() =>
useWatchKubeObjectLists({
kubeObjectClass: mockClass,
endpoint: { version: 'v1', resource: 'pods' },
lists,
}),
{
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
),
}
);

expect(mockSubscribe).toHaveBeenCalledTimes(2);
expect(mockSubscribe).toHaveBeenNthCalledWith(
1,
'cluster-a',
expect.stringContaining('/api/v1/namespaces/namespace-a/pods'),
'watch=1&resourceVersion=1',
expect.any(Function)
);
expect(mockSubscribe).toHaveBeenNthCalledWith(
2,
'cluster-b',
expect.stringContaining('/api/v1/namespaces/namespace-b/pods'),
'watch=1&resourceVersion=2',
expect.any(Function)
);
});

it('should handle non-namespaced resources', () => {
const lists = [{ cluster: 'cluster-a', resourceVersion: '1' }];

renderHook(
() =>
useWatchKubeObjectLists({
kubeObjectClass: mockClass,
endpoint: { version: 'v1', resource: 'pods' },
lists,
}),
{
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
),
}
);

expect(mockSubscribe).toHaveBeenCalledWith(
'cluster-a',
expect.stringContaining('/api/v1/pods'),
'watch=1&resourceVersion=1',
expect.any(Function)
);
});
});
Loading

0 comments on commit ab9c34b

Please sign in to comment.