Skip to content

Commit 6900085

Browse files
Gkrumbach07mprahl
andcommitted
feat(ui): Add workspace landing page and multi-workspace support
Signed-off-by: Gage Krumbach <gkrumbach@gmail.com> Co-Authored-By: Matt Prahl <mprahl@users.noreply.github.com>
1 parent 208e160 commit 6900085

76 files changed

Lines changed: 4600 additions & 1022 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

mlflow/server/js/src/MlflowRouter.tsx

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ import {
99
RouterProvider,
1010
Outlet,
1111
createLazyRouteElement,
12+
useLocation,
13+
useNavigate,
1214
useParams,
1315
usePageTitle,
1416
} from './common/utils/RoutingUtils';
1517
import { MlflowHeader } from './common/components/MlflowHeader';
1618
import { useDarkThemeContext } from './common/contexts/DarkThemeContext';
19+
import { useWorkspacesEnabled } from './common/utils/ServerFeaturesContext';
1720

1821
// Route definition imports:
1922
import { getRouteDefs as getExperimentTrackingRouteDefs } from './experiment-tracking/route-defs';
@@ -24,6 +27,17 @@ import { useInitializeExperimentRunColors } from './experiment-tracking/componen
2427
import { MlflowSidebar } from './common/components/MlflowSidebar';
2528
import { AssistantProvider, AssistantRouteContextProvider } from './assistant';
2629
import { RootAssistantLayout } from './common/components/RootAssistantLayout';
30+
import { extractWorkspaceFromPathname, setActiveWorkspace, getActiveWorkspace } from './common/utils/WorkspaceUtils';
31+
import { prefixRoutePathWithWorkspace } from './common/utils/WorkspaceRouteUtils';
32+
import { useWorkspaces } from './common/hooks/useWorkspaces';
33+
34+
type MlflowRouteDef = {
35+
path?: string;
36+
element?: React.ReactNode;
37+
pageId?: string;
38+
children?: MlflowRouteDef[];
39+
[key: string]: unknown;
40+
};
2741

2842
/**
2943
* This is root element for MLflow routes, containing app header.
@@ -89,9 +103,77 @@ const MlflowRootRoute = () => {
89103
</AssistantProvider>
90104
);
91105
};
106+
107+
const WorkspaceRouterSync = ({ workspacesEnabled }: { workspacesEnabled: boolean }) => {
108+
const location = useLocation();
109+
const navigate = useNavigate({ bypassWorkspacePrefix: true });
110+
const { workspaces, isLoading } = useWorkspaces(workspacesEnabled);
111+
112+
useEffect(() => {
113+
if (!workspacesEnabled) {
114+
setActiveWorkspace(null);
115+
return;
116+
}
117+
118+
const workspaceFromPath = extractWorkspaceFromPathname(location.pathname);
119+
const activeWorkspace = getActiveWorkspace();
120+
121+
if (isLoading) {
122+
return;
123+
}
124+
125+
// Validate workspace from path
126+
if (workspaceFromPath) {
127+
const isValid = workspaces.some((w) => w.name === workspaceFromPath);
128+
if (!isValid) {
129+
setActiveWorkspace(null);
130+
navigate('/', { replace: true });
131+
return;
132+
}
133+
if (activeWorkspace !== workspaceFromPath) {
134+
setActiveWorkspace(workspaceFromPath);
135+
}
136+
return;
137+
}
138+
139+
// If not in a workspace path and not on root, redirect to selector
140+
const isRootPath = location.pathname === '/' || location.pathname === '';
141+
if (!isRootPath) {
142+
setActiveWorkspace(null);
143+
navigate('/', { replace: true });
144+
return;
145+
}
146+
}, [location, navigate, workspacesEnabled, workspaces, isLoading]);
147+
148+
return null;
149+
};
150+
151+
const WorkspaceAwareRootRoute = ({ workspacesEnabled }: { workspacesEnabled: boolean }) => (
152+
<>
153+
<WorkspaceRouterSync workspacesEnabled={workspacesEnabled} />
154+
<MlflowRootRoute />
155+
</>
156+
);
157+
158+
const prependWorkspaceToRoutes = (routeDefs: MlflowRouteDef[], isChild = false): MlflowRouteDef[] =>
159+
routeDefs.map((route) => {
160+
// Only prepend workspace to child routes if they're absolute paths starting with /
161+
// Otherwise keep them relative to preserve React Router's nested routing
162+
const shouldPrependToPath = !isChild || (route.path && route.path.startsWith('/'));
163+
const children = route.children ? prependWorkspaceToRoutes(route.children, true) : undefined;
164+
165+
return {
166+
...route,
167+
path: shouldPrependToPath ? prefixRoutePathWithWorkspace(route.path) : route.path,
168+
...(children ? { children } : {}),
169+
};
170+
});
171+
92172
export const MlflowRouter = () => {
173+
const { workspacesEnabled, loading: featuresLoading } = useWorkspacesEnabled();
174+
93175
// eslint-disable-next-line react-hooks/rules-of-hooks
94-
const routes = useMemo(
176+
const routes = useMemo<MlflowRouteDef[]>(
95177
() => [
96178
...getExperimentTrackingRouteDefs(),
97179
...getModelRegistryRouteDefs(),
@@ -100,19 +182,37 @@ export const MlflowRouter = () => {
100182
],
101183
[],
102184
);
185+
186+
const workspaceRoutes = useMemo(
187+
() => (workspacesEnabled ? prependWorkspaceToRoutes(routes) : []),
188+
[routes, workspacesEnabled],
189+
);
190+
const combinedRoutes = useMemo(
191+
() => (workspacesEnabled ? [...routes, ...workspaceRoutes] : routes),
192+
[routes, workspaceRoutes, workspacesEnabled],
193+
);
194+
103195
// eslint-disable-next-line react-hooks/rules-of-hooks
104196
const hashRouter = useMemo(
105197
() =>
106-
createHashRouter([
107-
{
108-
path: '/',
109-
element: <MlflowRootRoute />,
110-
children: routes,
111-
},
112-
]),
113-
[routes],
198+
// Don't create router while still loading features
199+
featuresLoading
200+
? null
201+
: createHashRouter([
202+
{
203+
path: '/',
204+
element: <WorkspaceAwareRootRoute workspacesEnabled={workspacesEnabled} />,
205+
children: combinedRoutes,
206+
},
207+
]),
208+
[combinedRoutes, workspacesEnabled, featuresLoading],
114209
);
115210

211+
// Show loading skeleton while determining if workspaces are enabled
212+
if (featuresLoading || !hashRouter) {
213+
return <LegacySkeleton />;
214+
}
215+
116216
return (
117217
<React.Suspense fallback={<LegacySkeleton />}>
118218
<RouterProvider router={hashRouter} />

mlflow/server/js/src/app.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,37 @@ import { MlflowRouter as MlflowRouter } from './MlflowRouter';
2020
import { useMLflowDarkTheme } from './common/hooks/useMLflowDarkTheme';
2121
import { DarkThemeProvider } from './common/contexts/DarkThemeContext';
2222
import { telemetryClient } from './telemetry';
23+
import { subscribeToWorkspaceChanges } from './common/utils/WorkspaceUtils';
24+
import { ServerFeaturesProvider, SERVER_FEATURES_QUERY_KEY } from './common/utils/ServerFeaturesContext';
2325

2426
export function MLFlowRoot() {
2527
// eslint-disable-next-line react-hooks/rules-of-hooks
2628
const intl = useI18nInit();
29+
2730
// eslint-disable-next-line react-hooks/rules-of-hooks
31+
// Create clients once - we'll clear caches on workspace changes instead of recreating
2832
const apolloClient = useMemo(() => createApolloClient(), []);
2933
// eslint-disable-next-line react-hooks/rules-of-hooks
3034
const queryClient = useMemo(() => new QueryClient(), []);
3135

36+
// Clear caches when workspace changes instead of recreating clients
37+
useEffect(() => {
38+
const unsubscribe = subscribeToWorkspaceChanges(() => {
39+
// Clear React Query cache except server features (they don't change with workspace)
40+
queryClient.removeQueries({
41+
predicate: (query) => {
42+
// Keep server features cached
43+
return query.queryKey[0] !== SERVER_FEATURES_QUERY_KEY[0];
44+
},
45+
});
46+
47+
// Clear Apollo cache
48+
apolloClient.clearStore();
49+
});
50+
51+
return unsubscribe;
52+
}, [queryClient, apolloClient]);
53+
3254
// eslint-disable-next-line react-hooks/rules-of-hooks
3355
const [isDarkTheme, setIsDarkTheme, MlflowThemeGlobalStyles] = useMLflowDarkTheme();
3456

@@ -54,7 +76,9 @@ export function MLFlowRoot() {
5476
<MlflowThemeGlobalStyles />
5577
<DarkThemeProvider setIsDarkTheme={setIsDarkTheme}>
5678
<QueryClientProvider client={queryClient}>
57-
<MlflowRouter />
79+
<ServerFeaturesProvider>
80+
<MlflowRouter />
81+
</ServerFeaturesProvider>
5882
</QueryClientProvider>
5983
</DarkThemeProvider>
6084
</DesignSystemContainer>

mlflow/server/js/src/assistant/AssistantService.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import type { MessageRequest } from './types';
6-
import { getAjaxUrl } from '@mlflow/mlflow/src/common/utils/FetchUtils';
6+
import { getAjaxUrl, getDefaultHeaders } from '@mlflow/mlflow/src/common/utils/FetchUtils';
77

88
const API_BASE = getAjaxUrl('ajax-api/3.0/mlflow/assistant');
99

@@ -28,10 +28,12 @@ export const sendMessageStream = async (
2828
): Promise<void> => {
2929
try {
3030
// Step 1: POST the message to initiate processing
31+
// eslint-disable-next-line no-restricted-globals -- See go/spog-fetch
3132
const response = await fetch(`${API_BASE}/message`, {
3233
method: 'POST',
3334
headers: {
3435
'Content-Type': 'application/json',
36+
...getDefaultHeaders(document.cookie),
3537
},
3638
body: JSON.stringify(request),
3739
});

mlflow/server/js/src/common/components/ErrorView.test.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,33 @@
1-
import { describe, test, expect, it } from '@jest/globals';
1+
import { describe, test, expect, it, beforeAll, afterAll, jest } from '@jest/globals';
22
import React from 'react';
33
import { ErrorView } from './ErrorView';
44
import { renderWithIntl, screen } from '@mlflow/mlflow/src/common/utils/TestUtils.react18';
55
import { MemoryRouter } from '../utils/RoutingUtils';
6+
import { setActiveWorkspace } from '../utils/WorkspaceUtils';
7+
import { getWorkspacesEnabledSync } from '../utils/ServerFeaturesContext';
8+
9+
jest.mock('../utils/ServerFeaturesContext', () => ({
10+
...jest.requireActual<typeof import('../utils/ServerFeaturesContext')>('../utils/ServerFeaturesContext'),
11+
getWorkspacesEnabledSync: jest.fn(),
12+
}));
13+
14+
const getWorkspacesEnabledSyncMock = jest.mocked(getWorkspacesEnabledSync);
15+
16+
const TEST_WORKSPACE = 'test-workspace';
617

718
describe('ErrorView', () => {
19+
const workspacePrefixed = (path: string) => `/workspaces/${TEST_WORKSPACE}${path}`;
20+
21+
beforeAll(() => {
22+
getWorkspacesEnabledSyncMock.mockReturnValue(true);
23+
setActiveWorkspace(TEST_WORKSPACE);
24+
});
25+
26+
afterAll(() => {
27+
jest.restoreAllMocks();
28+
setActiveWorkspace(null);
29+
});
30+
831
test('should render 400', () => {
932
renderWithIntl(
1033
<MemoryRouter>
@@ -26,7 +49,7 @@ describe('ErrorView', () => {
2649

2750
const link = screen.getByRole('link');
2851
expect(link).toBeInTheDocument();
29-
expect(link).toHaveAttribute('href', '/path/to');
52+
expect(link).toHaveAttribute('href', workspacePrefixed('/path/to'));
3053
});
3154

3255
it('should render 404', () => {
@@ -50,7 +73,7 @@ describe('ErrorView', () => {
5073

5174
const link = screen.getByRole('link');
5275
expect(link).toBeInTheDocument();
53-
expect(link).toHaveAttribute('href', '/path/to');
76+
expect(link).toHaveAttribute('href', workspacePrefixed('/path/to'));
5477
});
5578

5679
test('should render 404 with sub message', () => {
@@ -74,6 +97,6 @@ describe('ErrorView', () => {
7497

7598
const link = screen.getByRole('link');
7699
expect(link).toBeInTheDocument();
77-
expect(link).toHaveAttribute('href', '/path/to');
100+
expect(link).toHaveAttribute('href', workspacePrefixed('/path/to'));
78101
});
79102
});

mlflow/server/js/src/common/components/MlflowHeader.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HomePageDocsUrl, Version } from '../constants';
44
import { DarkThemeSwitch } from '@mlflow/mlflow/src/common/components/DarkThemeSwitch';
55
import { Button, MenuIcon, useDesignSystemTheme } from '@databricks/design-system';
66
import { MlflowLogo } from './MlflowLogo';
7+
import { WorkspaceSelector } from './WorkspaceSelector';
78

89
export const MlflowHeader = ({
910
isDarkTheme = false,
@@ -66,6 +67,7 @@ export const MlflowHeader = ({
6667
</div>
6768
<div css={{ flex: 1 }} />
6869
<div css={{ display: 'flex', gap: theme.spacing.lg, alignItems: 'center' }}>
70+
<WorkspaceSelector />
6971
<DarkThemeSwitch isDarkTheme={isDarkTheme} setIsDarkTheme={setIsDarkTheme} />
7072
<a href="https://github.com/mlflow/mlflow">GitHub</a>
7173
<a href={HomePageDocsUrl}>Docs</a>

0 commit comments

Comments
 (0)