Skip to content

Commit 83b6f17

Browse files
committed
feature: Primary App option in Session creation notification
1 parent fe6e64e commit 83b6f17

37 files changed

+417
-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: 69 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,57 @@ 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+
// TODO: send appOption
52+
extraData: {
53+
appName: 'jupyter',
54+
urlPostfix: '&redirect=/notebooks/' + fileName,
55+
} as PrimaryAppOption,
56+
},
57+
]);
58+
}
59+
60+
if (results?.rejected && results.rejected.length > 0) {
61+
const error = results.rejected[0].reason;
62+
app.modal.error({
63+
title: error?.title,
64+
content: getErrorMessage(error),
65+
});
66+
}
67+
68+
webuiNavigate('/session');
69+
70+
// navigateWithSessionLauncher(launcherValue, {
71+
// appOption: {
72+
// runtime: 'jupyter',
73+
// filename: fileName,
74+
// } as AppOption,
75+
// });
76+
};
77+
2578
return (
2679
<Form ref={formRef} layout="inline" {...props}>
2780
<Form.Item
@@ -46,45 +99,20 @@ const ImportNotebook: React.FC<FormProps> = (props) => {
4699
>
47100
<Input placeholder={t('import.NotebookURL')} />
48101
</Form.Item>
49-
<Button
102+
<BAIButton
50103
icon={<CloudDownloadOutlined />}
51104
type="primary"
52-
onClick={() => {
53-
formRef.current
105+
action={async () => {
106+
const values = await formRef.current
54107
?.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(() => {});
108+
.catch(() => undefined);
109+
if (values) {
110+
await handleNotebookImport(values.url);
111+
}
84112
}}
85113
>
86114
{t('import.GetAndRunNotebook')}
87-
</Button>
115+
</BAIButton>
88116
</Form>
89117
);
90118
};

0 commit comments

Comments
 (0)