Skip to content

Commit 53cfb56

Browse files
committed
feat(FR-1659): enhance session UI with primary app actions and Jupyter notebook import (#4595)
### Enhance Session UI with Primary App Actions and Jupyter Notebook Import Resolves #3790 ([FR-1659](https://lablup.atlassian.net/browse/FR-1659)) This PR enhances the session creation workflow and notification system to provide better UX for primary applications like Jupyter, along with improvements to the notebook import functionality. ## Key Changes ### 🎯 Primary App Support in Session Notifications - Added primary app option to session creation notifications - Jupyter icon now appears as the primary action button when creating sessions via Jupyter import - Enhanced visual hierarchy to highlight the main application users will interact with ### 📓 Jupyter Notebook Import Improvements - Refactored notebook import to use the new `useStartSession` hook - Direct session creation flow without intermediate launcher steps - Automatic Jupyter redirect after notebook import with proper URL postfix ### 🔧 Bootstrap Script Enhancements - Added informative alert in session launcher preview when bootstrap script is configured - "See Detail" button to view the full bootstrap script content in a modal - Better visibility of custom initialization scripts ### 🛠️ Code Organization & Utilities - Moved `generateRandomString` utility to the shared UI package - Added `useGetAvailableFolderName` hook for unique folder name generation - Created reusable Jupyter icon component (`BAIJupyterIcon`) ## Testing Guide 1. **Primary App in Notifications:** - Import a Jupyter notebook via the Import page - Observe the session creation notification with Jupyter icon as primary action - Click the Jupyter button to launch the notebook directly 2. **Bootstrap Script Preview:** - Create a session with a bootstrap script - Check the info alert in the preview step - Click "See Detail" to view the script 3. **Folder Name Generation:** - Test folder creation with duplicate names - Verify unique name generation with random suffix **Checklist:** - [ ] Documentation - [ ] Minimum required manager version - [ ] Specific setting for review (eg., KB link, endpoint or how to setup) - [ ] Minimum requirements to check during review - [ ] Test case(s) to demonstrate the difference of before/after [FR-1659]: https://lablup.atlassian.net/browse/FR-1659?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent fe6e64e commit 53cfb56

37 files changed

+410
-82
lines changed

backend.ai-webui.code-workspace

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"i18n-ally.keystyle": "nested",
1515
"i18n-ally.dirStructure": "auto",
1616
"i18n-ally.displayLanguage": "en",
17-
"i18n-ally.sourceLanguage": "en",
17+
"i18n-ally.sourceLanguage": "ko",
18+
"i18n-ally.translate.promptSource": true,
1819
"i18n-ally.regex.usageMatch": [
1920
"<Trans\\b[^>]*?\\bi18nKey\\s*=\\s*\\{['\"`]({key})['\"`]\\}", // <Trans i18nKey={'key'}>
2021
"<Trans\\b[^>]*?\\bi18nKey\\s*=\\s*['\"`]({key})['\"`]", // <Trans i18nKey="key">

packages/backend.ai-ui/src/helper/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,3 +380,38 @@ export const omitNullAndUndefinedFields = <T extends Record<string, any>>(
380380
),
381381
) as Partial<T>;
382382
};
383+
384+
/**
385+
* Generates a random string of alphabetic characters.
386+
*
387+
* @param n - The length of the random string to generate. Defaults to 3.
388+
* @returns A random string containing uppercase and lowercase letters of the specified length.
389+
*
390+
* @example
391+
* ```typescript
392+
* generateRandomString(); // Returns a 3-character string like "AbC"
393+
* generateRandomString(5); // Returns a 5-character string like "XyZaB"
394+
* ```
395+
*
396+
* @remarks
397+
* The function uses a base-52 number system where:
398+
* - Characters 0-25 map to uppercase letters A-Z
399+
* - Characters 26-51 map to lowercase letters a-z
400+
*/
401+
export const generateRandomString = (n = 3) => {
402+
let randNum = Math.floor(Math.random() * 52 * 52 * 52);
403+
404+
const parseNum = (num: number) => {
405+
if (num < 26) return String.fromCharCode(65 + num);
406+
else return String.fromCharCode(97 + num - 26);
407+
};
408+
409+
let randStr = '';
410+
411+
for (let i = 0; i < n; i++) {
412+
randStr += parseNum(randNum % 52);
413+
randNum = Math.floor(randNum / 52);
414+
}
415+
416+
return randStr;
417+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { default as logo } from './Jupyter.svg?react';
2+
import Icon from '@ant-design/icons';
3+
import { CustomIconComponentProps } from '@ant-design/icons/lib/components/Icon';
4+
5+
// https://jupyter.org/governance/trademarks.html
6+
// https://github.com/jupyter/design/tree/main/logos/Logo%20Mark
7+
interface BAIHuggingFaceIconProps
8+
extends Omit<CustomIconComponentProps, 'width' | 'height' | 'fill'> {}
9+
10+
const BAIJupyterIcon: React.FC<BAIHuggingFaceIconProps> = (props) => {
11+
return <Icon component={logo} {...props} />;
12+
};
13+
14+
export default BAIJupyterIcon;
Lines changed: 42 additions & 0 deletions
Loading

packages/backend.ai-ui/src/icons/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,4 @@ export { default as BAIUserOutlinedIcon } from './BAIUserOutlinedIcon';
4040
export { default as BAIUserUnionIcon } from './BAIUserUnionIcon';
4141
export { default as BAIHuggingFaceIcon } from './BAIHuggingFaceIcon';
4242
export { default as BAISftpIcon } from './BAISftpIcon';
43+
export { default as BAIJupyterIcon } from './BAIJupyterIcon';

react/src/components/BAIComputeSessionNodeNotificationItem.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import SessionActionButtons from './ComputeSessionNodeItems/SessionActionButtons';
1+
import SessionActionButtons, {
2+
PrimaryAppOption,
3+
} from './ComputeSessionNodeItems/SessionActionButtons';
24
import SessionStatusTag from './ComputeSessionNodeItems/SessionStatusTag';
35
import { useUpdateEffect } from 'ahooks';
46
import { BAIFlex, BAILink, BAINotificationItem, BAIText } from 'backend.ai-ui';
@@ -24,10 +26,12 @@ interface BAINodeNotificationItemProps {
2426
notification: NotificationState;
2527
sessionFrgmt: BAIComputeSessionNodeNotificationItemFragment$key | null;
2628
showDate?: boolean;
29+
primaryAppOption?: PrimaryAppOption;
2730
}
31+
2832
const BAIComputeSessionNodeNotificationItem: React.FC<
2933
BAINodeNotificationItemProps
30-
> = ({ sessionFrgmt, showDate, notification }) => {
34+
> = ({ sessionFrgmt, showDate, notification, primaryAppOption }) => {
3135
const { destroyNotification } = useSetBAINotification();
3236
const { t } = useTranslation();
3337
const navigate = useNavigate();
@@ -105,6 +109,7 @@ const BAIComputeSessionNodeNotificationItem: React.FC<
105109
size="small"
106110
sessionFrgmt={node || null}
107111
hiddenButtonKeys={['containerCommit']}
112+
primaryAppOption={primaryAppOption}
108113
/>
109114
</BAIFlex>
110115
}

react/src/components/BAINodeNotificationItem.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const BAINodeNotificationItem: React.FC<{
3535
notification={notification}
3636
sessionFrgmt={node.sessionFrgmt || null}
3737
showDate={showDate}
38+
primaryAppOption={notification.extraData}
3839
/>
3940
);
4041
} else if (node?.__typename === 'VirtualFolderNode') {

react/src/components/ComputeSessionNodeItems/SessionActionButtons.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import { Tooltip, Button, theme, Space, ButtonProps } from 'antd';
1414
import {
1515
BAIAppIcon,
1616
BAIContainerCommitIcon,
17+
BAIJupyterIcon,
1718
BAISessionLogIcon,
1819
BAISftpIcon,
1920
BAITerminalAppIcon,
2021
BAITerminateIcon,
2122
BAIUnmountAfterClose,
23+
omitNullAndUndefinedFields,
2224
} from 'backend.ai-ui';
2325
import _ from 'lodash';
2426
import React, { Suspense, useState } from 'react';
@@ -33,12 +35,18 @@ type SessionActionButtonKey =
3335
| 'sftp'
3436
| 'terminate';
3537

38+
export type PrimaryAppOption = {
39+
appName: 'jupyter';
40+
urlPostfix?: string;
41+
};
42+
3643
interface SessionActionButtonsProps {
3744
sessionFrgmt: SessionActionButtonsFragment$key | null;
3845
size?: ButtonProps['size'];
3946
compact?: boolean;
4047
hiddenButtonKeys?: SessionActionButtonKey[];
4148
onAction?: (action: SessionActionButtonKey) => void;
49+
primaryAppOption?: PrimaryAppOption;
4250
}
4351

4452
const isActive = (session: SessionActionButtonsFragment$data) => {
@@ -66,6 +74,7 @@ const SessionActionButtons: React.FC<SessionActionButtonsProps> = ({
6674
compact,
6775
size,
6876
hiddenButtonKeys,
77+
primaryAppOption,
6978
onAction,
7079
}) => {
7180
const { t } = useTranslation();
@@ -137,14 +146,53 @@ const SessionActionButtons: React.FC<SessionActionButtonsProps> = ({
137146
return session ? (
138147
<>
139148
<Wrapper compact={compact}>
149+
{primaryAppOption && primaryAppOption.appName === 'jupyter' && (
150+
<Tooltip
151+
title={
152+
isButtonTitleMode
153+
? undefined
154+
: t('session.ExecuteSpecificApp', {
155+
appName: 'Jupyter Notebook',
156+
})
157+
}
158+
>
159+
<Button
160+
size={size}
161+
type={'primary'}
162+
disabled={
163+
!isAppSupported(session) || !isActive(session) || !isOwner
164+
}
165+
icon={<BAIJupyterIcon />}
166+
onClick={() => {
167+
const appOption = {
168+
'app-name': primaryAppOption.appName,
169+
'session-uuid': session?.row_id,
170+
'url-postfix': primaryAppOption.urlPostfix,
171+
};
172+
173+
// @ts-ignore
174+
globalThis.appLauncher._runApp(
175+
omitNullAndUndefinedFields(appOption),
176+
);
177+
}}
178+
title={
179+
isButtonTitleMode
180+
? t('session.ExecuteSpecificApp', {
181+
appName: 'Jupyter Notebook',
182+
})
183+
: undefined
184+
}
185+
/>
186+
</Tooltip>
187+
)}
140188
{isVisible('appLauncher') && (
141189
<>
142190
<Tooltip
143191
title={isButtonTitleMode ? undefined : t('session.SeeAppDialog')}
144192
>
145193
<Button
146194
size={size}
147-
type="primary"
195+
type={primaryAppOption ? undefined : 'primary'}
148196
disabled={
149197
!isAppSupported(session) || !isActive(session) || !isOwner
150198
}

react/src/components/ImportNotebook.tsx

Lines changed: 62 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
import { generateRandomString } from '../helper';
21
import { useSuspendedBackendaiClient, useWebUINavigate } from '../hooks';
3-
import {
4-
AppOption,
5-
SessionLauncherFormValue,
6-
} from '../pages/SessionLauncherPage';
7-
import { SessionLauncherPageLocationState } from './LocationStateBreadCrumb';
2+
import { PrimaryAppOption } from './ComputeSessionNodeItems/SessionActionButtons';
83
import { CloudDownloadOutlined } from '@ant-design/icons';
9-
import { Button, Form, FormInstance, FormProps, Input } from 'antd';
4+
import { App, Form, FormInstance, FormProps, Input } from 'antd';
5+
import {
6+
BAIButton,
7+
generateRandomString,
8+
useErrorMessageResolver,
9+
} from 'backend.ai-ui';
1010
import { useRef } from 'react';
1111
import { useTranslation } from 'react-i18next';
12+
import {
13+
StartSessionWithDefaultValue,
14+
useStartSession,
15+
} from 'src/hooks/useStartSession';
1216

1317
const regularizeGithubURL = (url: string) => {
1418
url = url.replace('/blob/', '/');
@@ -20,8 +24,50 @@ const ImportNotebook: React.FC<FormProps> = (props) => {
2024
url: string;
2125
}> | null>(null);
2226
const { t } = useTranslation();
27+
const app = App.useApp();
2328
const webuiNavigate = useWebUINavigate();
24-
useSuspendedBackendaiClient();
29+
const baiClient = useSuspendedBackendaiClient();
30+
const { startSessionWithDefault, upsertSessionNotification } =
31+
useStartSession();
32+
const { getErrorMessage } = useErrorMessageResolver();
33+
34+
const handleNotebookImport = async (url: string) => {
35+
const notebookURL = regularizeGithubURL(url);
36+
const fileName = notebookURL.split('/').pop();
37+
38+
const launcherValue: StartSessionWithDefaultValue = {
39+
sessionName: `imported-notebook-${generateRandomString(5)}`,
40+
environments: {
41+
version: baiClient._config.default_import_environment,
42+
},
43+
bootstrap_script: '#!/bin/sh\ncurl -O ' + notebookURL,
44+
};
45+
46+
const results = await startSessionWithDefault(launcherValue);
47+
if (results.fulfilled && results.fulfilled.length > 0) {
48+
// Handle successful result
49+
upsertSessionNotification(results.fulfilled, [
50+
{
51+
extraData: {
52+
appName: 'jupyter',
53+
urlPostfix: '&redirect=/notebooks/' + fileName,
54+
} as PrimaryAppOption,
55+
},
56+
]);
57+
}
58+
59+
if (results?.rejected && results.rejected.length > 0) {
60+
const error = results.rejected[0].reason;
61+
app.modal.error({
62+
title: error?.title,
63+
content: getErrorMessage(error),
64+
});
65+
}
66+
67+
webuiNavigate('/session');
68+
69+
};
70+
2571
return (
2672
<Form ref={formRef} layout="inline" {...props}>
2773
<Form.Item
@@ -46,45 +92,20 @@ const ImportNotebook: React.FC<FormProps> = (props) => {
4692
>
4793
<Input placeholder={t('import.NotebookURL')} />
4894
</Form.Item>
49-
<Button
95+
<BAIButton
5096
icon={<CloudDownloadOutlined />}
5197
type="primary"
52-
onClick={() => {
53-
formRef.current
98+
action={async () => {
99+
const values = await formRef.current
54100
?.validateFields()
55-
.then((values) => {
56-
const notebookURL = regularizeGithubURL(values.url);
57-
const launcherValue: DeepPartial<SessionLauncherFormValue> = {
58-
sessionName: 'imported-notebook-' + generateRandomString(5),
59-
environments: {
60-
environment: 'cr.backend.ai/stable/python',
61-
},
62-
bootstrap_script: '#!/bin/sh\ncurl -O ' + notebookURL,
63-
};
64-
const params = new URLSearchParams();
65-
params.set('step', '4');
66-
params.set('formValues', JSON.stringify(launcherValue));
67-
params.set(
68-
'appOption',
69-
JSON.stringify({
70-
runtime: 'jupyter',
71-
filename: notebookURL.split('/').pop(),
72-
} as AppOption),
73-
);
74-
webuiNavigate(`/session/start?${params.toString()}`, {
75-
state: {
76-
from: {
77-
pathname: '/import',
78-
label: t('webui.menu.Import&Run'),
79-
},
80-
} as SessionLauncherPageLocationState,
81-
});
82-
})
83-
.catch(() => {});
101+
.catch(() => undefined);
102+
if (values) {
103+
await handleNotebookImport(values.url);
104+
}
84105
}}
85106
>
86107
{t('import.GetAndRunNotebook')}
87-
</Button>
108+
</BAIButton>
88109
</Form>
89110
);
90111
};

react/src/components/ModelTryContentButton.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { ModelTryContentButtonVFolderNodeListQuery } from '../__generated__/Mode
33
import {
44
baiSignedRequestWithPromise,
55
compareNumberWithUnits,
6-
generateRandomString,
76
useBaiSignedRequestWithPromise,
87
} from '../helper';
98
import {
@@ -23,7 +22,7 @@ import {
2322
ServiceLauncherFormValue,
2423
} from './ServiceLauncherPageContent';
2524
import { Button } from 'antd';
26-
import { ESMClientErrorResponse } from 'backend.ai-ui';
25+
import { ESMClientErrorResponse, generateRandomString } from 'backend.ai-ui';
2726
import _ from 'lodash';
2827
import React from 'react';
2928
import { useTranslation } from 'react-i18next';

0 commit comments

Comments
 (0)