Skip to content
Closed
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
2 changes: 1 addition & 1 deletion cli/tests/data/compose.users.exp.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version: '3'
services:
testuser:
image: mltooling/ml-workspace-minimal:0.13.2
image: intocps/workspace:latest
volumes:
- /home/testuser/DTaaS/files/common:/workspace/common
- /home/testuser/DTaaS/files/testuser:/workspace
Expand Down
2 changes: 1 addition & 1 deletion cli/tests/data/compose.users.test.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version: '3'
services:
testuser:
image: mltooling/ml-workspace-minimal:0.13.2
image: intocps/workspace:latest
volumes:
- /home/testuser/DTaaS/files/common:/workspace/common
- /home/testuser/DTaaS/files/testuser:/workspace
Expand Down
2 changes: 1 addition & 1 deletion cli/users.local.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
image: mltooling/ml-workspace-minimal:0.13.2
image: intocps/workspace:latest
restart: unless-stopped
volumes:
- "${DTAAS_DIR}/files/common:/workspace/common"
Expand Down
2 changes: 1 addition & 1 deletion cli/users.server.secure.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
image: mltooling/ml-workspace-minimal:0.13.2
image: intocps/workspace:latest
restart: unless-stopped
volumes:
- "${DTAAS_DIR}/files/common:/workspace/common"
Expand Down
2 changes: 1 addition & 1 deletion cli/users.server.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
image: mltooling/ml-workspace-minimal:0.13.2
image: intocps/workspace:latest
restart: unless-stopped
volumes:
- "${DTAAS_DIR}/files/common:/workspace/common"
Expand Down
4 changes: 0 additions & 4 deletions client/config/dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ if (typeof window !== 'undefined') {
REACT_APP_URL_BASENAME: '',
REACT_APP_URL_DTLINK: '/lab',
REACT_APP_URL_LIBLINK: '',
REACT_APP_WORKBENCHLINK_VNCDESKTOP: '/tools/vnc/?password=vncpassword',
REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/',
REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab',
REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '',
REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library',
REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins',

Expand Down
4 changes: 0 additions & 4 deletions client/config/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ if (typeof window !== 'undefined') {
REACT_APP_URL_BASENAME: '',
REACT_APP_URL_DTLINK: '/lab',
REACT_APP_URL_LIBLINK: '',
REACT_APP_WORKBENCHLINK_VNCDESKTOP: '/tools/vnc/?password=vncpassword',
REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/',
REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab',
REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '',
REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library',
REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins',

Expand Down
4 changes: 0 additions & 4 deletions client/config/prod.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ if (typeof window !== 'undefined') {
REACT_APP_URL_BASENAME: '',
REACT_APP_URL_DTLINK: '/lab',
REACT_APP_URL_LIBLINK: '',
REACT_APP_WORKBENCHLINK_VNCDESKTOP: '/tools/vnc/?password=vncpassword',
REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/',
REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab',
REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '',
REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library',
REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins',

Expand Down
4 changes: 0 additions & 4 deletions client/config/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ if (typeof window !== 'undefined') {
REACT_APP_URL_BASENAME: '',
REACT_APP_URL_DTLINK: '/lab',
REACT_APP_URL_LIBLINK: '',
REACT_APP_WORKBENCHLINK_VNCDESKTOP: '/tools/vnc/?password=vncpassword',
REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/',
REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab',
REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '',
REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library',
REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins',

Expand Down
4 changes: 0 additions & 4 deletions client/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ declare global {
REACT_APP_URL_BASENAME: string;
REACT_APP_URL_DTLINK: string;
REACT_APP_URL_LIBLINK: string;
REACT_APP_WORKBENCHLINK_VNCDESKTOP: string;
REACT_APP_WORKBENCHLINK_VSCODE: string;
REACT_APP_WORKBENCHLINK_JUPYTERLAB: string;
REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: string;
REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: string;
REACT_APP_WORKBENCHLINK_DT_PREVIEW: string;

Expand Down
19 changes: 18 additions & 1 deletion client/src/route/workbench/Workbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import LinkButtons from 'components/LinkButtons';
import Layout from 'page/Layout';

import styled from '@emotion/styled';
import { useWorkbenchLinkValues } from 'util/envUtil';
import { useWorkbenchLinkValues, useAppURL } from 'util/envUtil';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from 'store/store';
import { fetchWorkbenchServices } from 'store/workbench.slice';
import { useEffect } from 'react';

const Container = styled.div`
display: flex;
Expand All @@ -14,6 +18,19 @@ const Container = styled.div`

function WorkBenchContent() {
const linkValues = useWorkbenchLinkValues();
const dispatch = useDispatch<AppDispatch>();
const username = useSelector((state: RootState) => state.auth).userName ?? '';
const servicesStatus = useSelector(
(state: RootState) => state.workbench.status,
);
const appURL = useAppURL();

useEffect(() => {
if (servicesStatus === 'idle' && username) {
dispatch(fetchWorkbenchServices(`${appURL}/${username}/services`));
}
}, [servicesStatus, username, appURL, dispatch]);

return (
<Layout sx={{ display: 'flex' }}>
<Paper
Expand Down
3 changes: 3 additions & 0 deletions client/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import cartSlice from 'model/store/cart.slice';
import menuSlice from 'store/menu.slice';
import authSlice from 'store/auth.slice';
import settingsSlice from 'store/settings.slice';
import workbenchSlice from 'store/workbench.slice';
import indexedDBService from 'database/executionHistoryDB';

setStorageService(indexedDBService);
Expand All @@ -32,6 +33,7 @@ const rootReducer = combineReducers({
libraryConfigFiles: libraryConfigFilesSlice,
settings: settingsSlice,
executionHistory: executionHistorySlice,
workbench: workbenchSlice,
});

const settingsPersistMiddleware: Middleware = (store) => (next) => (action) => {
Expand Down Expand Up @@ -68,5 +70,6 @@ const store = configureStore({
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export default store;
62 changes: 62 additions & 0 deletions client/src/store/workbench.slice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { z } from 'zod';

const WorkbenchServiceSchema = z.object({
name: z.string(),
description: z.string(),
endpoint: z.string(),
});

const WorkbenchServicesSchema = z.record(z.string(), WorkbenchServiceSchema);

export type WorkbenchService = z.infer<typeof WorkbenchServiceSchema>;

export interface WorkbenchServicesState {
services: Record<string, WorkbenchService>;
status: 'idle' | 'loading' | 'succeeded' | 'failed';
}

const initialState: WorkbenchServicesState = {
services: {},
status: 'idle',
};

export const fetchWorkbenchServices = createAsyncThunk(
'workbench/fetchServices',
async (url: string) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch services: ${response.statusText}`);
}
const data: unknown = await response.json();
return WorkbenchServicesSchema.parse(data);
},
);
Comment on lines +24 to +34
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

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

The fetch request lacks a timeout, which could cause indefinite hangs if the services endpoint is unresponsive. Following the codebase pattern (Authentication.ts uses 30000ms, configUtil.ts uses 2000ms for config checks), consider adding a timeout using AbortSignal.timeout. For a non-critical workbench feature fetch, a shorter timeout (e.g., 5000-10000ms) would improve user experience by failing faster.

Copilot uses AI. Check for mistakes.
Comment thread
prasadtalasila marked this conversation as resolved.

const workbenchSlice = createSlice({
name: 'workbench',
initialState,
reducers: {
setWorkbenchServices: (state, action) => {
state.services = action.payload as Record<string, WorkbenchService>;
state.status = 'succeeded';
},
resetWorkbench: () => initialState,
},
extraReducers: (builder) => {
builder
.addCase(fetchWorkbenchServices.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchWorkbenchServices.fulfilled, (state, action) => {
state.status = 'succeeded';
state.services = action.payload;
})
.addCase(fetchWorkbenchServices.rejected, (state) => {
state.status = 'failed';
});
},
});

export const { setWorkbenchServices, resetWorkbench } = workbenchSlice.actions;
export default workbenchSlice.reducer;
5 changes: 0 additions & 5 deletions client/src/util/configUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ const pathKeys = [
'REACT_APP_URL_BASENAME',
'REACT_APP_URL_DTLINK',
'REACT_APP_URL_LIBLINK',
'REACT_APP_WORKBENCHLINK_VNCDESKTOP',
'REACT_APP_WORKBENCHLINK_VSCODE',
'REACT_APP_WORKBENCHLINK_JUPYTERLAB',
'REACT_APP_WORKBENCHLINK_JUPYTERLAB',
'REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK',
'REACT_APP_CLIENT_ID',
'REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW',
'REACT_APP_WORKBENCHLINK_DT_PREVIEW',
Expand Down
41 changes: 29 additions & 12 deletions client/src/util/envUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,33 +60,50 @@ function buildUserLink(
/**
* @returns an array of `KeyLinkPair` objects, where each object contains a `key` and a `link`.
*
* The `key` is the `key` of the environment variable, with the prefix *"REACT_APP_WORKBENCHLINK_"* removed.
* For example, if the `key` of the environment variable is *"REACT_APP_WORKBENCHLINK_MYWORKBENCH"*, then the `key` will be *"MYWORKBENCH"*.
* Workspace tool links (Desktop, VS Code, Jupyter Lab, Jupyter Notebook) are derived from the
* services JSON fetched from `{appURL}/{username}/services` and stored in the Redux store.
*
* The `link` is constructed by appending the `username` to the end of the *REACT_APP_URL_WORKBENCH*, and then appending the value of the environment variable to the end of that.
* For example, if the *REACT_APP_URL_WORKBENCH* is https://foo.com, the `username` is *"user1"*, and the value of the environment variable is "/my-workbench", then the link will be https://foo.com/user1/my-workbench.
* Preview links (LIBRARY_PREVIEW, DT_PREVIEW) continue to be read from environment variables.
*/
export function useWorkbenchLinkValues(): KeyLinkPair[] {
const username = useSelector((state: RootState) => state.auth).userName ?? '';
const services = useSelector((state: RootState) => state.workbench.services);
const appURL = useAppURL();
const prefix = 'REACT_APP_WORKBENCHLINK_';
const workbenchLinkValues: KeyLinkPair[] = [];

const serviceKeyMap: Record<string, string> = {
desktop: 'VNCDESKTOP',
vscode: 'VSCODE',
lab: 'JUPYTERLAB',
notebook: 'JUPYTERNOTEBOOK',
};

Object.entries(serviceKeyMap).forEach(([serviceKey, iconKey]) => {
const service = services[serviceKey];
if (service !== undefined) {
workbenchLinkValues.push({
key: iconKey,
link: buildUserLink(username, appURL, service.endpoint),
});
}
});

const prefix = 'REACT_APP_WORKBENCHLINK_';
Object.keys(window.env)
.filter((key) => key.startsWith(prefix))
.forEach((key) => {
const value = window.env[key];
if (value !== undefined) {
const keyWithoutPrefix = key.slice(prefix.length);
const linkValue =
if (
keyWithoutPrefix === 'DT_PREVIEW' ||
keyWithoutPrefix === 'LIBRARY_PREVIEW'
? value
: buildUserLink(username, appURL, value);
workbenchLinkValues.push({
key: keyWithoutPrefix,
link: linkValue,
});
) {
workbenchLinkValues.push({
key: keyWithoutPrefix,
link: value,
});
}
}
});

Expand Down
4 changes: 0 additions & 4 deletions client/test/__mocks__/global_mocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,6 @@ globalThis.env = {
REACT_APP_URL_BASENAME: 'mock_url_basename',
REACT_APP_URL_DTLINK: '/lab',
REACT_APP_URL_LIBLINK: '',
REACT_APP_WORKBENCHLINK_VNCDESKTOP: '/tools/vnc/?foo=bar',
REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/',
REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab',
REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '',
REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library',
REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins',

Expand Down
45 changes: 38 additions & 7 deletions client/test/integration/Routes/Workbench.test.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,43 @@
import { screen, within } from '@testing-library/react';
import { screen, within, waitFor } from '@testing-library/react';
import {
itShowsTheTooltipWhenHoveringButton,
setupIntegrationTest,
} from 'test/integration/integration.testUtil';
import { testLayout } from 'test/integration/Routes/routes.testUtil';
import store from 'store/store';
import { setWorkbenchServices, resetWorkbench } from 'store/workbench.slice';

globalThis.env = {
...globalThis.env,
REACT_APP_URL: 'http://example.com/',
REACT_APP_URL_BASENAME: 'basename',
REACT_APP_WORKBENCHLINK_VNCDESKTOP: '/tools/vnc/?foo=bar',
REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/',
REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab',
REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '',
};

jest.deepUnmock('util/envUtil');

const mockServices = {
desktop: {
name: 'Desktop',
description: 'Virtual Desktop Environment',
endpoint: 'tools/vnc/?foo=bar',
},
vscode: {
name: 'VS Code',
description: 'VS Code IDE',
endpoint: 'tools/vscode',
},
lab: {
name: 'Jupyter Lab',
description: 'Jupyter Lab IDE',
endpoint: 'lab',
},
notebook: {
name: 'Jupyter Notebook',
description: 'Jupyter Notebook',
endpoint: '',
},
};

async function testTool(toolTipText: string, name: string) {
const toolDiv = screen.getByLabelText(toolTipText);
expect(toolDiv).toBeInTheDocument();
Expand All @@ -27,26 +48,36 @@ async function testTool(toolTipText: string, name: string) {
expect(toolButton).toBeInTheDocument();
}

const setup = () => setupIntegrationTest('/workbench');
const setup = async () => {
store.dispatch(setWorkbenchServices(mockServices));
await setupIntegrationTest('/workbench');
};

describe('Workbench', () => {
const desktopLabel =
'http://example.com/basename/username/tools/vnc/?foo=bar';
const VSCodeLabel = 'http://example.com/basename/username/tools/vscode';
const jupyterLabLabel = 'http://example.com/basename/username/lab';
const jupyterNotebookLabel = 'http://example.com/basename/username/';

beforeEach(async () => {
await setup();
});

afterEach(() => {
store.dispatch(resetWorkbench());
});

it('renders the Workbench and Layout correctly', async () => {
await testLayout();

const mainHeading = screen.getByRole('heading', { level: 4 });
expect(mainHeading).toBeInTheDocument();
expect(mainHeading).toHaveTextContent(/Workbench Tools/);

await testTool(desktopLabel, 'Desktop');
await waitFor(() => {
testTool(desktopLabel, 'Desktop');
});
await testTool(VSCodeLabel, 'VSCode');
await testTool(jupyterLabLabel, 'JupyterLab');
await testTool(jupyterNotebookLabel, 'Jupyter Notebook');
Expand Down
2 changes: 1 addition & 1 deletion client/test/unit/page/Config.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe('Config', () => {
});
expect(screen.getByText(/REACT_APP_URL_BASENAME/i)).toBeInTheDocument();
expect(
screen.getByText(/REACT_APP_WORKBENCHLINK_JUPYTERLAB/i),
screen.getByText(/REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW/i),
).toBeInTheDocument();
expect(
screen.getByText(/REACT_APP_LOGOUT_REDIRECT_URI/i),
Expand Down
Loading
Loading