Skip to content

Comments

feat(FR-991): migrate backend-ai-edu-applauncher to React#5389

Merged
inureyes merged 1 commit intomainfrom
feature/issue-5377-migrate-edu-applauncher-to-react
Feb 19, 2026
Merged

feat(FR-991): migrate backend-ai-edu-applauncher to React#5389
inureyes merged 1 commit intomainfrom
feature/issue-5377-migrate-edu-applauncher-to-react

Conversation

@inureyes
Copy link
Member

@inureyes inureyes commented Feb 19, 2026

Resolves #5377(FR-991)

Summary

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.

Test plan

  • Verify edu app launcher flow works with valid token query parameters
  • Verify session creation or reuse via session templates
  • Verify proxy connection is established correctly
  • Verify error notifications appear on authentication or session failures

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.
Copilot AI review requested due to automatic review settings February 19, 2026 08:28
@github-actions github-actions bot added area:ux UI / UX issue. effort:normal Need to understand a few modules / some extent of contextual or historical information. platform:web Web-specific issue type:enhance Add new features size:XL 500~ LoC labels Feb 19, 2026
@inureyes inureyes merged commit 8c18920 into main Feb 19, 2026
12 of 13 checks passed
@inureyes inureyes deleted the feature/issue-5377-migrate-edu-applauncher-to-react branch February 19, 2026 08:29
@github-actions
Copy link
Contributor

Coverage report for ./react

Caution

An unexpected error occurred. For more details, check console

Error: The process '/usr/bin/git' failed with exit code 128
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

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsx that 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-applauncher using the reactToWebComponent bridge with the value/JSON pattern
  • Updates backend-ai-webui.ts to use the new React component and removes the manual launch() call, allowing the React component to handle launch automatically via useEffect
  • Removes the lazy import of the old Lit component from backend-ai-app.ts
  • Completely deletes the old backend-ai-edu-applauncher.ts file (~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);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
_openServiceApp(sessionId, requestedApp);
await _openServiceApp(sessionId, requestedApp);

Copilot uses AI. Check for mistakes.
Comment on lines +294 to +296
const errText =
'Cannot create a session with an image different from any running session.';
notification.text = errText;
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +113 to +135
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];
};
};
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines +395 to +408
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);
}
};
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +443
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);
}
};
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@inureyes inureyes changed the title feat(FR-5377): migrate backend-ai-edu-applauncher to React feat(FR-991): migrate backend-ai-edu-applauncher to React Feb 19, 2026
inureyes added a commit that referenced this pull request Feb 19, 2026
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:ux UI / UX issue. effort:normal Need to understand a few modules / some extent of contextual or historical information. platform:web Web-specific issue size:XL 500~ LoC type:enhance Add new features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Migrate backend-ai-edu-applauncher to React

1 participant