Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

### Build and Production

- `pnpm run build` - Full production build (cleans `build/web/`, copies resources, builds React via Craco with Workbox service worker generation, builds workspace packages)
- `pnpm run build:react-only` - Build only React app via Craco
- `pnpm run build` - Full production build (cleans `build/web/`, copies resources, builds React via Vite with `vite-plugin-pwa` service worker generation, builds workspace packages)
- `pnpm run build:react-only` - Build only React app via Vite
- `pnpm run relay` - Compile GraphQL queries with Relay compiler

### Quality Control
Expand All @@ -23,8 +23,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

### Testing

- `pnpm run test` - Run Jest tests (root: `scripts/`, `src/`)
- `pnpm run test` (in `/react` directory) - Run React-specific Jest tests
- `pnpm run test` - Run Vitest tests (root: `scripts/`, `src/`)
- `pnpm run test` (in `/react` directory) - Run React-specific Vitest tests (`pnpm run vitest:watch` for watch mode)
- E2E tests (`/e2e/`) use Playwright; require full Backend.AI cluster running first

### Electron App
Expand All @@ -41,30 +41,31 @@ This is a **React web application** using React 19 + Ant Design 6 + Relay 20 (Gr

### Key Technologies

- **React Build**: Webpack via @craco/craco (Create React App with customizations)
- **React Build**: Vite 6 (`@vitejs/plugin-react`) with `vite-plugin-pwa`, `vite-plugin-svgr`, `vite-plugin-node-polyfills`
- **Component Library Build**: Vite (`packages/backend.ai-ui/`)
- **Service Worker**: workbox-webpack-plugin (GenerateSW, integrated into Craco/Webpack build)
- **Service Worker**: `vite-plugin-pwa` (Workbox under the hood), integrated into the Vite build
- **Package Manager**: pnpm with workspace monorepo
- **Styling**: Ant Design + antd-style
- **State Management**: Jotai (global UI state), Relay (server/GraphQL state)
- **GraphQL**: Relay compiler with projects for both `react/` and `packages/backend.ai-ui/`
- **React Compiler**: babel-plugin-react-compiler in annotation mode (`'use memo'` directive)
- **Testing**: Jest for unit tests, Playwright for E2E tests
- **Testing**: Vitest for unit tests (jsdom env), Playwright for E2E tests
- **Linting**: ESLint 9 (flat config) + Prettier, pre-commit hooks via Husky + lint-staged
- **Electron**: Desktop app wrapper with built-in websocket proxy
- **Storybook**: @storybook/react-vite for `backend.ai-ui` component library

### Project Structure

```
react/ # Main React application (Webpack/Craco)
react/ # Main React application (Vite)
src/ # Application source code
components/ # React UI components
pages/ # Page-level components
hooks/ # Custom React hooks
helper/ # Utility functions
__generated__/ # Relay compiler output
craco.config.cjs # Webpack customization via Craco
vite.config.ts # Vite + plugins (PWA / svgr / node-polyfills) configuration
vitest.config.ts # Vitest configuration (unit tests)
packages/ # Monorepo workspace packages
backend.ai-ui/ # Shared React component library (Vite build)
backend.ai-webui-docs/# User manual documentation
Expand All @@ -87,15 +88,15 @@ Production build (`pnpm run build`) runs these steps sequentially:
1. Clean and create `build/web/` output directory
2. Copy `index.html`, `resources/`, `manifest/`, config files
3. `pnpm run -r --stream build` builds all workspace packages:
- React app (Craco/Webpack) → `react/build/` → copied to `build/web/`
- Service worker (`sw.js`) generated by workbox-webpack-plugin during React build
- React app (Vite) → `react/build/` → copied to `build/web/`
- Service worker (`sw.js`) generated by `vite-plugin-pwa` during the React build
- backend.ai-ui (Vite) → `packages/backend.ai-ui/dist/`

### Development Workflow

1. **Dev Server**: Run `pnpm run dev` (TypeScript watch + Relay watch + React dev server under [Portless](https://github.com/vercel-labs/portless)). Portless is a `devDependency`, no global install needed; `dev.mjs` auto-starts the daemon on port 1355 (HTTPS by default).
2. **URL**: For branches matching `FR-XXXX` the dev URL is `https://fr-XXXX.localhost:1355`; otherwise Portless derives a branch-based subdomain (printed on startup). See `DEV_ENVIRONMENT.md` for theme color and troubleshooting.
3. **Testing**: Jest unit tests + Playwright E2E tests
3. **Testing**: Vitest unit tests + Playwright E2E tests
4. **Linting**: ESLint 9 (flat config) + Prettier with pre-commit hooks via Husky

# Additional Workflow Description
Expand Down Expand Up @@ -134,7 +135,8 @@ Production build (`pnpm run build`) runs these steps sequentially:
- **react-relay** 20, **relay-runtime** 20 - GraphQL client
- **jotai** - Atomic state management
- **i18next**, **react-i18next** - Internationalization
- **@craco/craco** - CRA webpack customization
- **vite** 6 + **@vitejs/plugin-react** - React app bundler and dev server
- **vitest** 4 - Unit test runner (jsdom env)
- **electron** 35 - Desktop app framework

### GraphQL/Relay Setup
Expand Down
3 changes: 1 addition & 2 deletions config.toml.sample
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ webServerURL = "[Web server website URL. App will use the site instead of local

[plugin]
# Reserved to load plugins
#login = "signup-cloud.js"
#sidebar = "report-cloud.js"
#login = "signup-cloud.js" # Inject a JavaScript file into the login page.
#page = "test-plugin1,test-plugin2" # Show menus on the list which are separated by comma.

#[license]
Expand Down
48 changes: 48 additions & 0 deletions e2e/plugin/backend-ai-homepage-link.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/* global document, MutationObserver */
/**
* E2E fixture for the login-screen homepage-link plugin.
*
* Served via `page.route` in `login-plugin.spec.ts`. Pure side-effect ES
* module — IIFE that finds the login form in DOM and appends a "Visit
* backend.ai" link. No exports.
*/
(function () {
'use strict';

const LINK_CLASS = 'bai-homepage-link';
const HREF = 'https://www.backend.ai/';
const LABEL = 'Visit backend.ai';

function inject() {
const form =
document.querySelector('.ant-modal-content form') ||
document.querySelector('.ant-modal-body form') ||
document.querySelector('form.ant-form') ||
document.querySelector('form');
if (!form) return false;
if (form.querySelector('.' + LINK_CLASS)) return true;

const wrapper = document.createElement('div');
wrapper.className = LINK_CLASS;
wrapper.style.textAlign = 'center';
wrapper.style.marginTop = '8px';
wrapper.style.fontSize = '13px';

const link = document.createElement('a');
link.href = HREF;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = LABEL;

wrapper.appendChild(link);
form.appendChild(wrapper);
return true;
}

if (inject()) return;

const observer = new MutationObserver(() => {
if (inject()) observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
})();
62 changes: 62 additions & 0 deletions e2e/plugin/login-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// E2E tests for the login-screen plugin slot (`config.plugin.login`).
//
// Login plugins follow the same runtime-fetch model as page plugins:
// `LoginView.tsx` imports `/dist/plugins/<name>.js` (or
// `${apiEndpoint}/dist/plugins/<name>.js` in Electron) at runtime, so
// `page.route` can fulfill the request with fixture content — the same
// pattern as the existing page-plugin tests in this directory.
import { webuiEndpoint, modifyConfigToml } from '../utils/test-util';
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';

const PLUGIN_NAME = 'backend-ai-homepage-link';
const HOMEPAGE_LINK_PLUGIN_JS = fs.readFileSync(
path.join(__dirname, `${PLUGIN_NAME}.js`),
'utf-8',
);

test.describe(
'Login Plugin',
{ tag: ['@plugin', '@functional', '@regression'] },
() => {
test('homepage-link plugin injects an external link into the login form', async ({
page,
request,
}) => {
await modifyConfigToml(page, request, {
plugin: { login: PLUGIN_NAME },
});

await page.route(`**/dist/plugins/${PLUGIN_NAME}.js`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/javascript',
body: HOMEPAGE_LINK_PLUGIN_JS,
});
});

await page.goto(webuiEndpoint);

const link = page.locator('.bai-homepage-link a');
await expect(link).toBeVisible();
await expect(link).toHaveAttribute('href', 'https://www.backend.ai/');
await expect(link).toHaveAttribute('target', '_blank');
await expect(link).toHaveText(/Visit backend\.ai/i);
});

test('no link appears when the plugin slot is unset', async ({
page,
request,
}) => {
await modifyConfigToml(page, request, {
plugin: { login: '' },
});

await page.goto(webuiEndpoint);

await page.locator('form').first().waitFor({ state: 'visible' });
await expect(page.locator('.bai-homepage-link')).toHaveCount(0);
});
},
);
27 changes: 17 additions & 10 deletions react/src/components/LoginView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,12 @@ const LoginView: React.FC<{
}
}, [isConfigLoaded, atomLoginConfig]);

// Load login plugin when config is ready
// Load login plugin when config is ready.
// Mirrors `PluginLoader.tsx`'s URL resolution so login plugins follow
// the same deployment model as page plugins: the file is served by the
// backend WebServer (or a Vite/static host that mounts `/dist/plugins/`).
// No build-time bundling — plugins can be deployed independently of the
// WebUI bundle.
useEffect(() => {
if (!isConfigLoaded || !loginPlugin) return;

Expand All @@ -189,17 +194,19 @@ const LoginView: React.FC<{
const sanitizedPlugin = loginPlugin.replace(/[^a-zA-Z0-9_-]/g, '');
if (!sanitizedPlugin || sanitizedPlugin !== loginPlugin) return;

// Build the plugin path at runtime, in a variable, so neither Vite nor
// esbuild's optimizeDeps scanner can statically analyze the specifier.
// A template-literal with a static prefix (e.g. `../../../src/plugins/…`)
// is treated by esbuild as a glob and fails to resolve in dev because
// `webui-ai/src/plugins/` only exists in production builds. `@vite-ignore`
// alone is not enough — esbuild's scanner does not honor that comment.
const pluginUrl = `../../../src/plugins/${sanitizedPlugin}`;
const isElectronEnv = (globalThis as Record<string, unknown>).isElectron;
const pluginUrl =
isElectronEnv && apiEndpoint
? `${apiEndpoint}/dist/plugins/${sanitizedPlugin}.js`
: `/dist/plugins/${sanitizedPlugin}.js`;

// `@vite-ignore` opts out of Vite's static analysis. The URL is an
// absolute path (no relative `..` traversal), so esbuild's
// optimizeDeps scanner does not treat it as a module specifier.
import(/* @vite-ignore */ pluginUrl).catch(() => {
setLoginError({ message: t('error.LoginFailed') });
setLoginError({ message: t('error.LoginPluginLoadFailed') });
});
}, [isConfigLoaded, loginPlugin, t]);
}, [isConfigLoaded, loginPlugin, apiEndpoint, t]);

// Keep configRef in sync
useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,7 @@
"InvalidUrl": "Es handelt sich nicht um eine gültige URL",
"LoginFailed": "Anmeldung fehlgeschlagen. Überprüfen Sie die Anmeldeinformationen.",
"LoginInformationMismatch": "Die Anmeldeinformationen stimmen nicht überein. Prüfen Sie Ihre Informationen",
"LoginPluginLoadFailed": "Das Anmelde-Plugin konnte nicht geladen werden.",
"LoginSucceededManagerNotResponding": "Anmeldung erfolgreich, aber Manager antwortet nicht.",
"MaximumVfolderCreation": "Sie können aufgrund von Ressourcenrichtlinien keine weiteren vOrdner erstellen",
"NetworkConnectionFailed": "Netzwerkverbindung fehlgeschlagen. Überprüfen Sie den Netzwerkstatus.",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/el.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,7 @@
"InvalidUrl": "Δεν είναι έγκυρη διεύθυνση URL",
"LoginFailed": "Η σύνδεση απέτυχε. Ελέγξτε τα στοιχεία σύνδεσης.",
"LoginInformationMismatch": "Αναντιστοιχία πληροφοριών σύνδεσης. Ελέγξτε τις πληροφορίες σας",
"LoginPluginLoadFailed": "Αποτυχία φόρτωσης του plugin σύνδεσης.",
"LoginSucceededManagerNotResponding": "Η σύνδεση έγινε επιτυχής, αλλά ο διαχειριστής δεν αποκρίνεται.",
"MaximumVfolderCreation": "Δεν μπορείτε να δημιουργήσετε περισσότερους φακέλους v λόγω της πολιτικής πόρων",
"NetworkConnectionFailed": "Η σύνδεση δικτύου απέτυχε. Ελέγξτε την κατάσταση του δικτύου.",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1342,6 +1342,7 @@
"InvalidUrl": "It is not a valid URL",
"LoginFailed": "Login failed. Check login information.",
"LoginInformationMismatch": "Login information mismatch. Check your information",
"LoginPluginLoadFailed": "Failed to load login plugin.",
"LoginSucceededManagerNotResponding": "Login succeed but manager is not responding.",
"MaximumVfolderCreation": "You cannot create more vfolders due to resource policy",
"NetworkConnectionFailed": "Network connection failed. Check network status.",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,7 @@
"InvalidUrl": "No es una URL válida",
"LoginFailed": "Error en el inicio de sesión. Compruebe la información de inicio de sesión.",
"LoginInformationMismatch": "La información de inicio de sesión no coincide. Compruebe sus datos",
"LoginPluginLoadFailed": "No se pudo cargar el complemento de inicio de sesión.",
"LoginSucceededManagerNotResponding": "El inicio de sesión se ha realizado correctamente, pero el administrador no responde.",
"MaximumVfolderCreation": "No se pueden crear más vfolders debido a la política de recursos",
"NetworkConnectionFailed": "Ha fallado la conexión de red. Compruebe el estado de la red.",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,7 @@
"InvalidUrl": "Se ei ole kelvollinen URL-osoite",
"LoginFailed": "Kirjautuminen epäonnistui. Tarkista kirjautumistiedot.",
"LoginInformationMismatch": "Kirjautumistietojen epäsuhta. Tarkista tietosi",
"LoginPluginLoadFailed": "Kirjautumislaajennuksen lataaminen epäonnistui.",
"LoginSucceededManagerNotResponding": "Sisäänkirjautuminen onnistuu, mutta johtaja ei vastaa.",
"MaximumVfolderCreation": "Et voi luoda lisää v-kansioita resurssikäytännön vuoksi.",
"NetworkConnectionFailed": "Verkkoyhteys epäonnistui. Tarkista verkon tila.",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,7 @@
"InvalidUrl": "Ce n'est pas une URL valide",
"LoginFailed": "La connexion a échoué. Vérifier les informations de connexion.",
"LoginInformationMismatch": "Les informations de connexion ne correspondent pas. Vérifiez vos informations",
"LoginPluginLoadFailed": "Impossible de charger le plugin de connexion.",
"LoginSucceededManagerNotResponding": "La connexion a réussi mais le gestionnaire ne répond pas.",
"MaximumVfolderCreation": "Vous ne pouvez pas créer d'autres dossiers virtuels en raison de la politique de ressources.",
"NetworkConnectionFailed": "La connexion réseau a échoué. Vérifier l'état du réseau.",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/id.json
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,7 @@
"InvalidUrl": "Ini bukan URL yang valid",
"LoginFailed": "Login gagal. Periksa informasi login.",
"LoginInformationMismatch": "Ketidakcocokan informasi login. Periksa informasi Anda",
"LoginPluginLoadFailed": "Gagal memuat plugin login.",
"LoginSucceededManagerNotResponding": "Login berhasil tetapi pengelola tidak merespons.",
"MaximumVfolderCreation": "Anda tidak dapat membuat lebih banyak vfolder karena kebijakan sumber daya",
"NetworkConnectionFailed": "Koneksi jaringan gagal. Periksa status jaringan.",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,7 @@
"InvalidUrl": "Non è un URL valido",
"LoginFailed": "Accesso non riuscito. Controllare le informazioni di accesso.",
"LoginInformationMismatch": "Le informazioni di accesso non corrispondono. Controllare le informazioni",
"LoginPluginLoadFailed": "Impossibile caricare il plugin di accesso.",
"LoginSucceededManagerNotResponding": "Accesso riuscito ma il gestore non risponde.",
"MaximumVfolderCreation": "Non è possibile creare altre vfolders a causa del criterio delle risorse.",
"NetworkConnectionFailed": "Connessione di rete fallita. Controllare lo stato della rete.",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,7 @@
"InvalidUrl": "有効な URL ではありません",
"LoginFailed": "ログインに失敗しました。ログイン情報を確認してください。",
"LoginInformationMismatch": "ログイン情報が一致しません。情報を確認する",
"LoginPluginLoadFailed": "ログインプラグインを読み込めませんでした。",
"LoginSucceededManagerNotResponding": "ログインは成功しましたが、マネージャーが応答していません。",
"MaximumVfolderCreation": "リソースポリシーのため、Vフォルダーをさらに作成することはできません。",
"NetworkConnectionFailed": "ネットワーク接続に失敗しました。ネットワークの状態を確認してください。",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,7 @@
"InvalidUrl": "유효한 URL이 아닙니다.",
"LoginFailed": "로그인하지 못했습니다. 로그인 정보를 확인하세요.",
"LoginInformationMismatch": "로그인에 실패했습니다. 이메일(또는 사용자 이름)과 비밀번호를 다시 확인해 주세요.",
"LoginPluginLoadFailed": "로그인 플러그인을 가져오지 못했습니다.",
"LoginSucceededManagerNotResponding": "로그인은 성공했으나 매니저 서버가 응답하지 않습니다.",
"MaximumVfolderCreation": "자원 정책에 따라 더 이상 폴더를 만들 수 없습니다.",
"NetworkConnectionFailed": "네트워크 연결 실패. 네트워크 상태를 확인하세요.",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/mn.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,7 @@
"InvalidUrl": "Энэ нь хүчинтэй URL биш байна",
"LoginFailed": "Нэвтэрч чадсангүй. \nНэвтрэх мэдээллийг шалгана уу.",
"LoginInformationMismatch": "Нэвтрэх мэдээлэл таарахгүй байна. \nМэдээллээ шалгана уу",
"LoginPluginLoadFailed": "Нэвтрэх плагиныг ачаалж чадсангүй.",
"LoginSucceededManagerNotResponding": "Нэвтрэлт амжилттай болсон боловч менежер хариу өгөхгүй байна.",
"MaximumVfolderCreation": "Нөөцийн бодлогын улмаас та илүү олон v хавтас үүсгэх боломжгүй",
"NetworkConnectionFailed": "Сүлжээний холболт амжилтгүй боллоо. \nСүлжээний статусыг шалгана уу.",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/ms.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,7 @@
"InvalidUrl": "Ia bukan URL yang sah",
"LoginFailed": "Daftar masuk gagal. \nSemak maklumat log masuk.",
"LoginInformationMismatch": "Maklumat log masuk tidak sepadan. \nSemak maklumat anda",
"LoginPluginLoadFailed": "Gagal memuatkan pemalam log masuk.",
"LoginSucceededManagerNotResponding": "Log masuk berjaya tetapi pengurus tidak bertindak balas.",
"MaximumVfolderCreation": "Anda tidak boleh membuat lebih banyak vfolder kerana dasar sumber",
"NetworkConnectionFailed": "Sambungan rangkaian gagal. \nSemak status rangkaian.",
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,7 @@
"InvalidUrl": "To nie jest prawidłowy adres URL",
"LoginFailed": "Logowanie nie powiodło się. Sprawdź dane logowania.",
"LoginInformationMismatch": "Niezgodność danych logowania. Sprawdź swoje dane",
"LoginPluginLoadFailed": "Nie udało się załadować wtyczki logowania.",
"LoginSucceededManagerNotResponding": "Logowanie powiodło się, ale menedżer nie odpowiada.",
"MaximumVfolderCreation": "Nie można utworzyć więcej folderów wirtualnych ze względu na zasady dotyczące zasobów",
"NetworkConnectionFailed": "Połączenie sieciowe nie powiodło się. Sprawdź stan sieci.",
Expand Down
Loading
Loading