feat(FR-991): migrate backend-ai-edu-applauncher to React#5389
Conversation
Replaces the Lit-Element backend-ai-edu-applauncher with a React EduAppLauncher component registered as backend-ai-react-edu-applauncher. The React component: - Uses useEffect with a once-only guard to run the launch sequence when the component becomes active - Initializes the backend.ai client via globalThis.BackendAIClient - Authenticates via token from URL query parameters - Prepares project/group information - Queries existing sessions or creates new ones via session templates - Delegates proxy operations to the existing backend-ai-app-launcher Lit web component (_open_wsproxy, _connectToProxyWorker) - Uses globalThis.lablupNotification and lablupIndicator for feedback Updates backend-ai-webui.ts to use the React web component with the value/JSON pattern and removes the manual launch() call. Updates backend-ai-app.ts to remove the lazy import of the old component.
Coverage report for
|
St.❔ |
Category | Percentage | Covered / Total |
|---|---|---|---|
| 🔴 | Statements | 4.33% | 633/14628 |
| 🔴 | Branches | 4.01% | 406/10122 |
| 🔴 | Functions | 2.5% | 106/4247 |
| 🔴 | Lines | 4.19% | 599/14288 |
Report generated by 🧪jest coverage report action from 9494b5c
There was a problem hiding this comment.
Pull request overview
This PR migrates the backend-ai-edu-applauncher component from Lit-Element to React as part of the ongoing effort to remove all Lit/MWC/Vaadin dependencies from the codebase (epic #5364). The education app launcher is a specialized component that handles token-based authentication, session creation/reuse, and app launching for educational scenarios.
Changes:
- Creates a new React component
EduAppLauncher.tsxthat replicates the functionality of the old Lit component with token authentication, session management, and app launching - Registers the component as a web component
backend-ai-react-edu-applauncherusing thereactToWebComponentbridge with the value/JSON pattern - Updates
backend-ai-webui.tsto use the new React component and removes the manuallaunch()call, allowing the React component to handle launch automatically viauseEffect - Removes the lazy import of the old Lit component from
backend-ai-app.ts - Completely deletes the old
backend-ai-edu-applauncher.tsfile (~555 lines)
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| react/src/components/EduAppLauncher.tsx | New React component implementing education app launcher with token auth, session query/creation, and app launching via the existing backend-ai-app-launcher web component |
| react/src/index.tsx | Registers backend-ai-react-edu-applauncher web component with lazy loading and proper prop parsing |
| src/components/backend-ai-webui.ts | Updates to use new React component with value/JSON pattern, removes manual launch() call and setTimeout wrapper |
| src/backend-ai-app.ts | Removes lazy import statement for old Lit component |
| src/components/backend-ai-edu-applauncher.ts | Complete deletion of old Lit component file |
| const sessionId = urlParams.get('session_id') || null; | ||
| if (sessionId) { | ||
| const requestedApp = urlParams.get('app') || 'jupyter'; | ||
| _openServiceApp(sessionId, requestedApp); |
There was a problem hiding this comment.
The call to _openServiceApp is not awaited, while _createEduSession is awaited on line 441. This inconsistency could lead to issues if the component unmounts before _openServiceApp completes. For consistency and proper error handling, consider adding await before _openServiceApp(sessionId, requestedApp).
| _openServiceApp(sessionId, requestedApp); | |
| await _openServiceApp(sessionId, requestedApp); |
| const errText = | ||
| 'Cannot create a session with an image different from any running session.'; | ||
| notification.text = errText; |
There was a problem hiding this comment.
This error message is hard-coded in English and should be internationalized. The message should use a translation key from the i18n system. Consider adding an appropriate key to the eduapi namespace (e.g., eduapi.CannotCreateSessionWithDifferentImage) and use t() to translate it.
| const _prepareProjectInformation = async () => { | ||
| const fields = ['email', 'groups {name, id}']; | ||
| const query = `query { user{ ${fields.join(' ')} } }`; | ||
| const response = await g.backendaiclient.query(query, {}); | ||
|
|
||
| g.backendaiclient.groups = response.user.groups | ||
| .map((item: { name: string }) => item.name) | ||
| .sort(); | ||
| g.backendaiclient.groupIds = response.user.groups.reduce( | ||
| (acc: Record<string, string>, group: { name: string; id: string }) => { | ||
| acc[group.name] = group.id; | ||
| return acc; | ||
| }, | ||
| {}, | ||
| ); | ||
| const currentProject = g.backendaiutils._readRecentProjectGroup(); | ||
| g.backendaiclient.current_group = currentProject | ||
| ? currentProject | ||
| : g.backendaiclient.groups[0]; | ||
| g.backendaiclient.current_group_id = () => { | ||
| return g.backendaiclient.groupIds[g.backendaiclient.current_group]; | ||
| }; | ||
| }; |
There was a problem hiding this comment.
The _prepareProjectInformation function lacks error handling. If the GraphQL query fails or returns unexpected data (e.g., empty groups array), the code could throw an error. Consider wrapping this in a try-catch block and handling potential errors gracefully, similar to how other async functions in this component handle errors with _handleError().
| const _handleError = (err: any, notification: any) => { | ||
| if (err?.message) { | ||
| if (err.description) { | ||
| notification.text = err.description; | ||
| } else { | ||
| notification.text = err.message; | ||
| } | ||
| notification.detail = err.message; | ||
| notification.show(true, err); | ||
| } else if (err?.title) { | ||
| notification.text = err.title; | ||
| notification.show(true, err); | ||
| } | ||
| }; |
There was a problem hiding this comment.
The error handling in _handleError function doesn't use the usePainKiller hook to translate common error messages, unlike other React components in the codebase (e.g., ContainerRegistryList, ImageInstallModal, TerminateSessionModal). This means error messages won't be properly translated when they match known error patterns. Consider using const { relieve } = usePainKiller() and wrapping error messages with relieve() before displaying them, similar to how the original Lit component used PainKiller.relieve().
| const _initClient = async (endpoint: string) => { | ||
| const webUIShell: any = document.querySelector('#webui-shell'); | ||
| let resolvedEndpoint = endpoint; | ||
|
|
||
| if (resolvedEndpoint === '') { | ||
| const storedEndpoint = localStorage.getItem( | ||
| 'backendaiwebui.api_endpoint', | ||
| ); | ||
| if (storedEndpoint != null) { | ||
| resolvedEndpoint = storedEndpoint.replace(/^"+|"+$/g, ''); | ||
| } | ||
| } | ||
| resolvedEndpoint = resolvedEndpoint.trim(); | ||
|
|
||
| const clientConfig = new g.BackendAIClientConfig( | ||
| '', | ||
| '', | ||
| resolvedEndpoint, | ||
| 'SESSION', | ||
| ); | ||
| g.backendaiclient = new g.BackendAIClient( | ||
| clientConfig, | ||
| 'Backend.AI Web UI.', | ||
| ); | ||
| const configPath = '../../config.toml'; | ||
| await webUIShell._parseConfig(configPath); | ||
| g.backendaiclient._config._proxyURL = webUIShell.config.wsproxy.proxyURL; | ||
| await g.backendaiclient.get_manager_version(); | ||
| g.backendaiclient.ready = true; | ||
| }; | ||
|
|
||
| /** | ||
| * Authenticate via token from URL query parameters. | ||
| */ | ||
| const _token_login = async (): Promise<boolean> => { | ||
| const notification = g.lablupNotification; | ||
| const urlParams = new URLSearchParams(window.location.search); | ||
| const sToken = urlParams.get('sToken') || urlParams.get('stoken') || null; | ||
|
|
||
| if (sToken !== null) { | ||
| document.cookie = `sToken=${sToken}; expires=Session; path=/`; | ||
| } | ||
|
|
||
| const extraParams: Record<string, string> = {}; | ||
| for (const [key, value] of urlParams.entries()) { | ||
| if (key !== 'sToken' && key !== 'stoken') { | ||
| extraParams[key] = value; | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| const alreadyLoggedIn = await g.backendaiclient.check_login(); | ||
| if (!alreadyLoggedIn) { | ||
| const loginSuccess = await g.backendaiclient.token_login( | ||
| sToken, | ||
| extraParams, | ||
| ); | ||
| if (!loginSuccess) { | ||
| notification.text = t('eduapi.CannotAuthorizeSessionByToken'); | ||
| notification.show(true); | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } catch (err) { | ||
| logger.error('Token login failed:', err); | ||
| notification.text = t('eduapi.CannotAuthorizeSessionByToken'); | ||
| notification.show(true, err); | ||
| return false; | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Fetch and cache group/project information for the current user. | ||
| */ | ||
| const _prepareProjectInformation = async () => { | ||
| const fields = ['email', 'groups {name, id}']; | ||
| const query = `query { user{ ${fields.join(' ')} } }`; | ||
| const response = await g.backendaiclient.query(query, {}); | ||
|
|
||
| g.backendaiclient.groups = response.user.groups | ||
| .map((item: { name: string }) => item.name) | ||
| .sort(); | ||
| g.backendaiclient.groupIds = response.user.groups.reduce( | ||
| (acc: Record<string, string>, group: { name: string; id: string }) => { | ||
| acc[group.name] = group.id; | ||
| return acc; | ||
| }, | ||
| {}, | ||
| ); | ||
| const currentProject = g.backendaiutils._readRecentProjectGroup(); | ||
| g.backendaiclient.current_group = currentProject | ||
| ? currentProject | ||
| : g.backendaiclient.groups[0]; | ||
| g.backendaiclient.current_group_id = () => { | ||
| return g.backendaiclient.groupIds[g.backendaiclient.current_group]; | ||
| }; | ||
| }; | ||
|
|
||
| /** | ||
| * Open a service app for an existing session using the app launcher proxy. | ||
| * Delegates to the Lit-based backend-ai-app-launcher web component for | ||
| * proxy operations (_open_wsproxy, _connectToProxyWorker). | ||
| */ | ||
| const _openServiceApp = async (sessionId: string, requestedApp: string) => { | ||
| const notification = g.lablupNotification; | ||
| const indicator = await g.lablupIndicator.start(); | ||
|
|
||
| try { | ||
| const appLauncherEl = document.querySelector( | ||
| 'backend-ai-app-launcher', | ||
| ) as any; | ||
| if (!appLauncherEl) { | ||
| notification.text = t('session.appLauncher.ConnectUrlIsNotValid'); | ||
| notification.show(true); | ||
| indicator.end(); | ||
| return; | ||
| } | ||
|
|
||
| const resp = await appLauncherEl._open_wsproxy( | ||
| sessionId, | ||
| requestedApp, | ||
| null, | ||
| null, | ||
| ); | ||
| if (resp?.url) { | ||
| const appRespUrl = await appLauncherEl._connectToProxyWorker( | ||
| resp.url, | ||
| '', | ||
| ); | ||
| const appConnectUrl = String(appRespUrl?.appConnectUrl) || resp.url; | ||
| if (!appConnectUrl) { | ||
| indicator.end(); | ||
| notification.text = t('session.appLauncher.ConnectUrlIsNotValid'); | ||
| notification.show(true); | ||
| return; | ||
| } | ||
| indicator.set(100, t('session.appLauncher.Prepared')); | ||
| setTimeout(() => { | ||
| g.open(appConnectUrl, '_self'); | ||
| }); | ||
| } | ||
| } catch (err) { | ||
| logger.error('Failed to open service app:', err); | ||
| indicator.end(); | ||
| _handleError(err, g.lablupNotification); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Create or reuse a compute session using the session template, | ||
| * then launch the app. | ||
| */ | ||
| const _createEduSession = async ( | ||
| resources: Record<string, string | null>, | ||
| ) => { | ||
| const notification = g.lablupNotification; | ||
| const indicator = await g.lablupIndicator.start(); | ||
| const eduAppNamePrefix = g.backendaiclient._config.eduAppNamePrefix || ''; | ||
|
|
||
| const statusList = [ | ||
| 'RUNNING', | ||
| 'RESTARTING', | ||
| 'TERMINATING', | ||
| 'PENDING', | ||
| 'SCHEDULED', | ||
| g.backendaiclient.supports('prepared-session-status') | ||
| ? 'PREPARED' | ||
| : undefined, | ||
| g.backendaiclient.supports('creating-session-status') | ||
| ? 'CREATING' | ||
| : undefined, | ||
| 'PREPARING', | ||
| 'PULLING', | ||
| ] | ||
| .filter(Boolean) | ||
| .join(','); | ||
|
|
||
| const sessionFields = [ | ||
| 'session_id', | ||
| 'name', | ||
| 'access_key', | ||
| 'status', | ||
| 'status_info', | ||
| 'service_ports', | ||
| 'mounts', | ||
| ]; | ||
| const accessKey = g.backendaiclient._config.accessKey; | ||
|
|
||
| let sessions: any; | ||
| try { | ||
| indicator.set(20, t('eduapi.QueryingExistingComputeSession')); | ||
| sessions = await g.backendaiclient.computeSession.list( | ||
| sessionFields, | ||
| statusList, | ||
| accessKey, | ||
| 30, | ||
| 0, | ||
| ); | ||
| } catch (err) { | ||
| indicator.end(); | ||
| _handleError(err, notification); | ||
| return; | ||
| } | ||
|
|
||
| const urlParams = new URLSearchParams(window.location.search); | ||
| const requestedApp = urlParams.get('app') || 'jupyter'; | ||
| let parsedAppName = requestedApp; | ||
| const sessionTemplateName = | ||
| urlParams.get('session_template') || | ||
| urlParams.get('sessionTemplate') || | ||
| requestedApp; | ||
|
|
||
| if (eduAppNamePrefix !== '' && requestedApp.startsWith(eduAppNamePrefix)) { | ||
| parsedAppName = requestedApp.slice(eduAppNamePrefix.length); | ||
| } | ||
|
|
||
| let sessionTemplates: any[]; | ||
| try { | ||
| const allTemplates = await g.backendaiclient.sessionTemplate.list(false); | ||
| sessionTemplates = allTemplates.filter( | ||
| (tmpl: any) => tmpl.name === sessionTemplateName, | ||
| ); | ||
| } catch (err) { | ||
| indicator.end(); | ||
| _handleError(err, notification); | ||
| return; | ||
| } | ||
|
|
||
| if (sessionTemplates.length < 1) { | ||
| indicator.end(); | ||
| notification.text = t('eduapi.NoSessionTemplate'); | ||
| notification.show(true); | ||
| return; | ||
| } | ||
|
|
||
| const requestedSessionTemplate = sessionTemplates[0]; | ||
| let launchNewSession = true; | ||
| let sessionId: string | null = null; | ||
|
|
||
| if (sessions.compute_session_list.total_count > 0) { | ||
| let matchedSession: Record<string, unknown> | null = null; | ||
|
|
||
| for (let i = 0; i < sessions.compute_session_list.items.length; i++) { | ||
| const sess = sessions.compute_session_list.items[i]; | ||
| const sessionImage = sess.image; | ||
| const servicePorts = JSON.parse(sess.service_ports || '{}'); | ||
| const services: string[] = | ||
| Object.keys(servicePorts).map((s: string) => servicePorts[s].name) || | ||
| []; | ||
| const sessionStatus = sess.status; | ||
|
|
||
| if ( | ||
| sessionImage !== requestedSessionTemplate.template.spec.kernel.image | ||
| ) { | ||
| indicator.end(); | ||
| const errText = | ||
| 'Cannot create a session with an image different from any running session.'; | ||
| notification.text = errText; | ||
| notification.show(true, errText); | ||
| return; | ||
| } | ||
|
|
||
| if (sessionStatus !== 'RUNNING') { | ||
| indicator.end(); | ||
| notification.text = | ||
| t('eduapi.SessionStatusIs') + | ||
| ` ${sessionStatus}. ` + | ||
| t('eduapi.PleaseReload'); | ||
| notification.show(true); | ||
| return; | ||
| } | ||
|
|
||
| if (services.includes(parsedAppName)) { | ||
| matchedSession = sess; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (matchedSession) { | ||
| launchNewSession = false; | ||
| sessionId = | ||
| 'session_id' in matchedSession | ||
| ? (matchedSession.session_id as string) | ||
| : null; | ||
| indicator.set(50, t('eduapi.FoundExistingComputeSession')); | ||
| } else { | ||
| launchNewSession = true; | ||
| } | ||
| } | ||
|
|
||
| if (launchNewSession) { | ||
| indicator.set(40, t('eduapi.FindingSessionTemplate')); | ||
| const templateId = requestedSessionTemplate.id; | ||
|
|
||
| try { | ||
| const mounts = await g.backendaiclient.eduApp.get_mount_folders(); | ||
| const projects = await g.backendaiclient.eduApp.get_user_projects(); | ||
|
|
||
| if (!projects) { | ||
| notification.text = t('eduapi.EmptyProject'); | ||
| notification.show(); | ||
| return; | ||
| } | ||
|
|
||
| const sToken = urlParams.get('sToken') || urlParams.get('stoken'); | ||
| const credentialScript = sToken | ||
| ? (await g.backendaiclient.eduApp.get_user_credential(sToken))[ | ||
| 'script' | ||
| ] | ||
| : undefined; | ||
|
|
||
| const sessionResources = { | ||
| ...resources, | ||
| group_name: projects[0]['name'], | ||
| ...(mounts && Object.keys(mounts).length > 0 ? { mounts } : {}), | ||
| ...(credentialScript ? { bootstrap_script: credentialScript } : {}), | ||
| }; | ||
|
|
||
| try { | ||
| indicator.set(60, t('eduapi.CreatingComputeSession')); | ||
| const response = await g.backendaiclient.createSessionFromTemplate( | ||
| templateId, | ||
| null, | ||
| null, | ||
| sessionResources, | ||
| 20000, | ||
| ); | ||
| sessionId = response.sessionId; | ||
| } catch (err: any) { | ||
| indicator.end(); | ||
| _handleError(err, notification); | ||
| return; | ||
| } | ||
| } catch (err: any) { | ||
| indicator.end(); | ||
| if (err?.message && 'statusCode' in err && err.statusCode === 408) { | ||
| notification.text = t('eduapi.SessionStillPreparing'); | ||
| notification.detail = err.message; | ||
| notification.show(true, err); | ||
| } else { | ||
| _handleError(err, notification); | ||
| } | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| indicator.set(100, t('eduapi.ComputeSessionPrepared')); | ||
|
|
||
| if (sessionId) { | ||
| _openServiceApp(sessionId, parsedAppName); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Handle API errors and show notification messages. | ||
| */ | ||
| const _handleError = (err: any, notification: any) => { | ||
| if (err?.message) { | ||
| if (err.description) { | ||
| notification.text = err.description; | ||
| } else { | ||
| notification.text = err.message; | ||
| } | ||
| notification.detail = err.message; | ||
| notification.show(true, err); | ||
| } else if (err?.title) { | ||
| notification.text = err.title; | ||
| notification.show(true, err); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Main launch sequence: init client, token login, prepare project, | ||
| * then start or reuse session and launch the app. | ||
| */ | ||
| const _launch = async (endpoint: string) => { | ||
| try { | ||
| await _initClient(endpoint); | ||
| } catch (err) { | ||
| logger.error('Failed to initialize client:', err); | ||
| return; | ||
| } | ||
|
|
||
| const urlParams = new URLSearchParams(window.location.search); | ||
| const resources: Record<string, string | null> = { | ||
| cpu: urlParams.get('cpu'), | ||
| mem: urlParams.get('mem'), | ||
| shmem: urlParams.get('shmem'), | ||
| 'cuda.shares': urlParams.get('cuda-shares'), | ||
| 'cuda.device': urlParams.get('cuda-device'), | ||
| }; | ||
|
|
||
| const loginSuccess = await _token_login(); | ||
| if (!loginSuccess) return; | ||
|
|
||
| await _prepareProjectInformation(); | ||
|
|
||
| const sessionId = urlParams.get('session_id') || null; | ||
| if (sessionId) { | ||
| const requestedApp = urlParams.get('app') || 'jupyter'; | ||
| _openServiceApp(sessionId, requestedApp); | ||
| } else { | ||
| await _createEduSession(resources); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Function names starting with underscore (_initClient, _token_login, _prepareProjectInformation, _openServiceApp, _createEduSession, _handleError, _launch) are not following JavaScript/TypeScript conventions. In JavaScript/TypeScript, underscore prefixes typically indicate private members in classes, but these are just internal helper functions. Consider removing the underscore prefix for cleaner code that follows modern JavaScript conventions.
- Add Secure/SameSite flags and encodeURIComponent to sToken cookie - Fix String() coercion that made falsy check unreachable in _openServiceApp - Replace hardcoded English error with i18n key CannotCreateSessionWithDifferentImage - Replace string concatenation with parameterized i18n key SessionStatusIsWithReload - Add user notification on _initClient failure - Add error handling for _prepareProjectInformation call - Add new i18n keys to all 21 language files
Resolves #5377(FR-991)
Summary
Replaces the Lit-Element
backend-ai-edu-applauncherwith a ReactEduAppLaunchercomponent registered asbackend-ai-react-edu-applauncher.The React component:
useEffectwith a once-only guard to run the launch sequence when the component becomes activeglobalThis.BackendAIClientbackend-ai-app-launcherLit web component (_open_wsproxy,_connectToProxyWorker)globalThis.lablupNotificationandlablupIndicatorfor feedbackUpdates
backend-ai-webui.tsto use the React web component with the value/JSON pattern and removes the manuallaunch()call.Updates
backend-ai-app.tsto remove the lazy import of the old component.Test plan