Skip to content

Commit 77e1ef1

Browse files
committed
feat(FR-1568): migrate app launcher notifications from Lit to React (#4617)
Resolves #TBD ([FR-1568](https://lablup.atlassian.net/browse/FR-1568)) ## 📋 Overview Migration of the app launcher notification system from Lit-based (`lablup-notification`) to React-based notification system in Backend.AI. ## 🔄 App Launch & Notification Flow ### 1. **App Launch Request Initiation** (React → Lit) **`AppLauncherModal.tsx` (lines 108-133)** - Create notification in React component with session info and app name - Call Lit component's `_runApp` with unified notification key ### 2. **App Launch Process** (Lit Component) **`backend-ai-app-launcher.ts`** updates notification at each stage during app launch: #### Phase 1: Initial Setup (lines 1251-1255) - Start backgroundTask at 10% - Status: `pending` #### Phase 2: Proxy Configuration - **V1 Proxy** (lines 715-720): Progress at 20% - **V2 Proxy** (lines 773-780): Error handling with `status: 'failed'` #### Phase 3: Socket Queue Addition (lines 902-909) - Progress at 50% - Adding kernel to socket queue #### Phase 4: App Launch Completion (lines 1375-1387) - Progress at 100% - Status: `succeeded` - Update notification after showing TCP app dialogs (SSH, VNC, etc.) #### Phase 5: Final Cleanup (lines 1447-1451) - Final notification display in finally block ### 3. **Error Handling** Notification alerts users on errors: - Proxy setup failure (lines 697-702, 727-734) - Session creation failure (lines 821-826) - Service port unavailable (lines 835-841) - Connection failure (lines 916-930) ## 🎯 Key Changes 1. **Unified Notification Key**: - Shared key (`session-app-${sessionUuid}`) between React and Lit components for state synchronization 2. **Progress Tracking**: - Visual progress via `backgroundTask` property (0% → 10% → 20% → 50% → 100%) - Status transitions: `pending` → `succeeded` or `failed` 3. **Error Handling**: - Updates to `status: 'failed'` on failure at any stage - User-friendly error messages ## 📝 Review Points - Notification state synchronization between React and Lit components - Prevention of duplicate notifications using shared notification key - Real-time progress updates for improved UX - Clear feedback on error occurrences ## ✅ Testing - [x] App launch notifications appear correctly - [x] Progress indicators update during app launch - [x] Error states display appropriate messages - [x] No duplicate notifications with same key - [x] Backward compatibility maintained [FR-1568]: https://lablup.atlassian.net/browse/FR-1568?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent d409b1b commit 77e1ef1

29 files changed

+327
-78
lines changed

react/src/components/BAIComputeSessionNodeNotificationItem.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ interface BAINodeNotificationItemProps {
3232
const BAIComputeSessionNodeNotificationItem: React.FC<
3333
BAINodeNotificationItemProps
3434
> = ({ sessionFrgmt, showDate, notification, primaryAppOption }) => {
35-
const { destroyNotification } = useSetBAINotification();
35+
const { closeNotification } = useSetBAINotification();
3636
const { t } = useTranslation();
3737
const navigate = useNavigate();
3838
const node = useFragment(
@@ -70,7 +70,7 @@ const BAIComputeSessionNodeNotificationItem: React.FC<
7070
useUpdateEffect(() => {
7171
if (node?.status === 'TERMINATED' || node?.status === 'CANCELLED') {
7272
setTimeout(() => {
73-
destroyNotification(notification.key);
73+
closeNotification(notification.key);
7474
}, 3000);
7575
}
7676
}, [node?.status]);
@@ -90,7 +90,7 @@ const BAIComputeSessionNodeNotificationItem: React.FC<
9090
navigate(
9191
`/session${node.row_id ? `?${new URLSearchParams({ sessionDetail: node.row_id }).toString()}` : ''}`,
9292
);
93-
destroyNotification(notification.key);
93+
closeNotification(notification.key);
9494
}}
9595
>
9696
{node.name}

react/src/components/BAINotificationButton.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,34 @@ import useKeyboardShortcut from 'src/hooks/useKeyboardShortcut';
1616
export const isOpenDrawerState = atom(false);
1717

1818
const BAINotificationButton: React.FC<ButtonProps> = ({ ...props }) => {
19-
const [notifications, { upsertNotification }] = useBAINotificationState();
19+
const [notifications, { upsertNotification, clearNotification }] =
20+
useBAINotificationState();
2021
useBAINotificationEffect();
2122

2223
const [isOpenDrawer, setIsOpenDrawer] = useAtom(isOpenDrawerState);
2324
useEffect(() => {
24-
const handler = (e: any) => {
25+
const addNotificationHandler = (e: any) => {
2526
upsertNotification(e.detail);
2627
};
27-
document.addEventListener('add-bai-notification', handler);
28+
const clearNotificationHandler = (e: any) => {
29+
clearNotification(e.detail.key);
30+
};
31+
document.addEventListener('add-bai-notification', addNotificationHandler);
32+
document.addEventListener(
33+
'clear-bai-notification',
34+
clearNotificationHandler,
35+
);
2836
return () => {
29-
document.removeEventListener('add-bai-notification', handler);
37+
document.removeEventListener(
38+
'add-bai-notification',
39+
addNotificationHandler,
40+
);
41+
document.removeEventListener(
42+
'clear-bai-notification',
43+
clearNotificationHandler,
44+
);
3045
};
31-
}, [upsertNotification]);
46+
}, [upsertNotification, clearNotification]);
3247

3348
useKeyboardShortcut(
3449
(event) => {

react/src/components/ComputeSessionNodeItems/AppLauncherModal.tsx

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,20 @@ import {
1818
Row,
1919
Typography,
2020
} from 'antd';
21-
import { BAIFlex, BAIModal, BAISelect, BAIText } from 'backend.ai-ui';
21+
import {
22+
BAIButton,
23+
BAIFlex,
24+
BAILink,
25+
BAIModal,
26+
BAISelect,
27+
BAIText,
28+
} from 'backend.ai-ui';
2229
import _ from 'lodash';
2330
import { useRef, useState } from 'react';
2431
import { Trans, useTranslation } from 'react-i18next';
2532
import { graphql, useFragment } from 'react-relay';
33+
import { useNavigate } from 'react-router-dom';
34+
import { useSetBAINotification } from 'src/hooks/useBAINotification';
2635

2736
interface AppLauncherModalProps extends ModalProps {
2837
onRequestClose: () => void;
@@ -43,6 +52,9 @@ const AppLauncherModal: React.FC<AppLauncherModalProps> = ({
4352
const [forceUseV2Proxy, setForceUseV2Proxy] = useState<boolean>(false);
4453
const [useSubDomain, setUseSubDomain] = useState<boolean>(false);
4554

55+
const { upsertNotification } = useSetBAINotification();
56+
const navigate = useNavigate();
57+
4658
const session = useFragment(
4759
graphql`
4860
fragment AppLauncherModalFragment on ComputeSessionNode {
@@ -92,9 +104,38 @@ const AppLauncherModal: React.FC<AppLauncherModalProps> = ({
92104
}
93105
});
94106

107+
// set notification for lit-element component
108+
upsertNotification({
109+
key: `session-app-${session?.row_id}`,
110+
message: (
111+
<span>
112+
{t('general.Session')}:&nbsp;
113+
<BAILink
114+
style={{
115+
fontWeight: 'normal',
116+
}}
117+
onClick={() => {
118+
const newSearchParams = new URLSearchParams(location.search);
119+
newSearchParams.set('sessionDetail', session?.row_id || '');
120+
navigate({
121+
pathname: `/session`,
122+
search: newSearchParams.toString(),
123+
});
124+
}}
125+
>
126+
{session?.name}
127+
</BAILink>
128+
</span>
129+
),
130+
description: t('session.appLauncher.LaunchingApp', {
131+
appName: app?.title || '',
132+
}),
133+
});
134+
95135
const appController = {
96136
'app-name': app?.name ?? '',
97137
'session-uuid': session?.row_id ?? '',
138+
'session-name': session?.name ?? '',
98139
'url-postfix': app?.redirect ?? '',
99140
};
100141

@@ -121,10 +162,7 @@ const AppLauncherModal: React.FC<AppLauncherModalProps> = ({
121162
return;
122163
}
123164
// @ts-ignore
124-
globalThis.appLauncher._runApp(appController).then(() => {});
125-
setOpenToPublic(false);
126-
setTryPreferredPort(false);
127-
onRequestClose();
165+
await globalThis.appLauncher._runApp(appController).then(() => {});
128166
};
129167

130168
return (
@@ -172,7 +210,7 @@ const AppLauncherModal: React.FC<AppLauncherModalProps> = ({
172210
gap={'xs'}
173211
style={{ height: '100%' }}
174212
>
175-
<Button
213+
<BAIButton
176214
icon={
177215
<Image
178216
src={app?.src}
@@ -181,8 +219,12 @@ const AppLauncherModal: React.FC<AppLauncherModalProps> = ({
181219
style={{ height: 36, width: 36 }}
182220
/>
183221
}
184-
onClick={() => {
185-
handleAppLaunch(app);
222+
action={async () => {
223+
await handleAppLaunch(app).then(() => {
224+
setOpenToPublic(false);
225+
setTryPreferredPort(false);
226+
onRequestClose();
227+
});
186228
}}
187229
style={{ height: 72, width: 72 }}
188230
/>

react/src/components/FileUploadManager.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const FileUploadManager: React.FC = () => {
7373
const { t } = useTranslation();
7474
const { token } = theme.useToken();
7575
const baiClient = useSuspendedBackendaiClient();
76-
const { upsertNotification, destroyNotification } = useSetBAINotification();
76+
const { upsertNotification, closeNotification } = useSetBAINotification();
7777
const { generateFolderPath } = useFolderExplorerOpener();
7878
const [uploadRequests, setUploadRequests] = useAtom(uploadRequestAtom);
7979
const [uploadStatus, setUploadStatus] = useAtom(uploadStatusAtom);
@@ -176,7 +176,7 @@ const FileUploadManager: React.FC = () => {
176176
}}
177177
to={generateFolderPath(vFolderId)}
178178
onClick={() => {
179-
destroyNotification('upload:' + vFolderId);
179+
closeNotification('upload:' + vFolderId);
180180
}}
181181
>{`${vFolderName}`}</BAILink>
182182
</span>
@@ -306,7 +306,7 @@ const FileUploadManager: React.FC = () => {
306306
}}
307307
to={generateFolderPath(vFolderId)}
308308
onClick={() => {
309-
destroyNotification('upload:' + vFolderId);
309+
closeNotification('upload:' + vFolderId);
310310
}}
311311
>{`${status?.vFolderName}`}</BAILink>
312312
</span>

react/src/hooks/useBAINotification.tsx

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { createStyles } from 'antd-style';
77
import { ArgsProps } from 'antd/lib/notification';
88
import { atom, useAtomValue, useSetAtom } from 'jotai';
99
import _ from 'lodash';
10-
import { Key, ReactNode, useCallback, useEffect, useRef } from 'react';
10+
import React, { Key, ReactNode, useCallback, useEffect, useRef } from 'react';
1111
import { useTranslation } from 'react-i18next';
1212
import { To, createPath } from 'react-router-dom';
1313
import { BAINodeNotificationItemFragment$key } from 'src/__generated__/BAINodeNotificationItemFragment.graphql';
@@ -289,23 +289,42 @@ export const useSetBAINotification = () => {
289289
const webuiNavigate = useWebUINavigate();
290290
const { styles } = useStyle();
291291

292-
const destroyAllNotifications = useCallback(() => {
292+
const closeAllNotifications = useCallback(() => {
293293
_activeNotificationKeys.splice(0, _activeNotificationKeys.length);
294294
app.notification.destroy();
295295
}, [app.notification]);
296296

297+
/**
298+
* Function to permanently clear all notifications.
299+
*/
297300
const clearAllNotifications = useCallback(() => {
298301
setNotifications([]);
299-
destroyAllNotifications();
300-
}, [setNotifications, destroyAllNotifications]);
302+
closeAllNotifications();
303+
}, [setNotifications, closeAllNotifications]);
301304

302-
const destroyNotification = useCallback(
305+
/**
306+
* Function to hide specific notification. It remains in the drawer.
307+
*/
308+
const closeNotification = useCallback(
303309
(key: React.Key) => {
304310
app.notification.destroy(key);
305311
},
306312
[app.notification],
307313
);
308314

315+
/**
316+
* Function to remove specific notification from the list and hide it.
317+
*/
318+
const clearNotification = useCallback(
319+
(key: React.Key) => {
320+
setNotifications((prev) => {
321+
return prev.filter((n) => n.key !== key);
322+
});
323+
closeNotification(key);
324+
},
325+
[setNotifications, closeNotification],
326+
);
327+
309328
/**
310329
* Function to upsert a notification.
311330
* @param params - The parameters for the notification.
@@ -398,7 +417,7 @@ export const useSetBAINotification = () => {
398417
if (newNotification.to) {
399418
webuiNavigate(newNotification.to);
400419
}
401-
destroyNotification(newNotification.key);
420+
closeNotification(newNotification.key);
402421
}}
403422
/>
404423
),
@@ -412,6 +431,12 @@ export const useSetBAINotification = () => {
412431
});
413432
if (idx >= 0) {
414433
setNotifications((prevList) => {
434+
// check the notification is removed by clearNotification function. If so, do nothing.
435+
const exists = prevList.some(
436+
(n) => n.key === newNotification.key,
437+
);
438+
if (!exists) return prevList;
439+
415440
const newList = [...prevList];
416441
newList[idx] = {
417442
...newList[idx],
@@ -423,7 +448,7 @@ export const useSetBAINotification = () => {
423448
},
424449
});
425450
} else if (newNotification.open === false && newNotification.key) {
426-
destroyNotification(newNotification.key);
451+
closeNotification(newNotification.key);
427452
}
428453
currentKey = newNotification.key;
429454
return nextNotifications;
@@ -435,7 +460,7 @@ export const useSetBAINotification = () => {
435460
[
436461
app.notification,
437462
setNotifications,
438-
destroyNotification,
463+
closeNotification,
439464
desktopNotification,
440465
],
441466
);
@@ -467,9 +492,10 @@ export const useSetBAINotification = () => {
467492

468493
return {
469494
upsertNotification,
495+
clearNotification,
470496
clearAllNotifications,
471-
destroyNotification,
472-
destroyAllNotifications,
497+
closeNotification,
498+
closeAllNotifications,
473499
};
474500
};
475501

react/src/hooks/useBackendAIAppLauncher.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1+
import { useSetBAINotification } from './useBAINotification';
2+
import { BAILink } from 'backend.ai-ui';
3+
import { useTranslation } from 'react-i18next';
14
import { graphql, useFragment } from 'react-relay';
5+
import { useNavigate } from 'react-router-dom';
26
import { useBackendAIAppLauncherFragment$key } from 'src/__generated__/useBackendAIAppLauncherFragment.graphql';
37

48
export const useBackendAIAppLauncher = (
59
sessionFrgmt?: useBackendAIAppLauncherFragment$key | null,
610
) => {
11+
const { t } = useTranslation();
12+
const { upsertNotification } = useSetBAINotification();
13+
const navigate = useNavigate();
14+
715
// TODO: migrate backend-ai-app-launcher features to this hook using fragment data.
816
const session = useFragment(
917
graphql`
1018
fragment useBackendAIAppLauncherFragment on ComputeSessionNode {
19+
name
1120
row_id @required(action: NONE)
1221
vfolder_mounts
1322
}
@@ -17,7 +26,35 @@ export const useBackendAIAppLauncher = (
1726

1827
// @ts-ignore
1928
return {
29+
// TODO: AppLauncherModal should modify the hook to use, as it is currently separated,
30+
// to re-declare the notification for use by the backend-ai-app-launcher.
2031
runTerminal: () => {
32+
upsertNotification({
33+
key: `session-app-${session?.row_id}`,
34+
message: (
35+
<span>
36+
{t('general.Session')}:&nbsp;
37+
<BAILink
38+
style={{
39+
fontWeight: 'normal',
40+
}}
41+
onClick={() => {
42+
const newSearchParams = new URLSearchParams(location.search);
43+
newSearchParams.set('sessionDetail', session?.row_id || '');
44+
navigate({
45+
pathname: `/session`,
46+
search: newSearchParams.toString(),
47+
});
48+
}}
49+
>
50+
{session?.name}
51+
</BAILink>
52+
</span>
53+
),
54+
description: t('session.appLauncher.LaunchingApp', {
55+
appName: 'Console',
56+
}),
57+
});
2158
// @ts-ignore
2259
globalThis.appLauncher.runTerminal(session.row_id);
2360
},

resources/i18n/de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,6 +1423,7 @@
14231423
"ConfirmAndRun": "Ich habe nachgesehen und ich fange an",
14241424
"ConnectUrlIsNotValid": "Die Verbindungs-URL ist ungültig.",
14251425
"DownloadSSHKey": "SSH-Schlüssel herunterladen",
1426+
"LaunchingApp": "Die App {{appName}} wird gestartet.",
14261427
"NoExistingConnectionExample": "Keine Verbindung Zu kopierendes Beispiel.",
14271428
"OpenVSCodeRemote": "Lokalen Visual Studio Code öffnen",
14281429
"Prepared": "Bereit",

resources/i18n/el.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,6 +1422,7 @@
14221422
"ConfirmAndRun": "Έλεγξα και θα ξεκινήσω",
14231423
"ConnectUrlIsNotValid": "Η διεύθυνση URL σύνδεσης δεν είναι έγκυρη.",
14241424
"DownloadSSHKey": "Λήψη κλειδιού SSH",
1425+
"LaunchingApp": "Η εφαρμογή {{appName}} εκκινείται.",
14251426
"NoExistingConnectionExample": "Δεν υπάρχει σύνδεση Παράδειγμα προς αντιγραφή.",
14261427
"OpenVSCodeRemote": "Ανοίξτε τον τοπικό κώδικα του Visual Studio",
14271428
"Prepared": "Ετοιμος",

resources/i18n/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,6 +1431,7 @@
14311431
"ConfirmAndRun": "I checked and I'll start",
14321432
"ConnectUrlIsNotValid": "Connect URL is not valid.",
14331433
"DownloadSSHKey": "Download SSH Key",
1434+
"LaunchingApp": "Starting {{appName}} app.",
14341435
"NoExistingConnectionExample": "No Connection Example to be copied.",
14351436
"OpenVSCodeRemote": "Open local Visual Studio Code",
14361437
"Prepared": "Prepared",

0 commit comments

Comments
 (0)