From 699386b5e301f458279d3a8384b20360fb88a56c Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Tue, 14 Apr 2026 12:36:58 +0900 Subject: [PATCH] refactor(FR-2493): centralize resource slot metadata into BAIMetaDataProvider - Move useResourceSlotsDetails API call from BUI to webui, populate BAIMetaDataProvider context - Add unified useBAIMetaData() hook in BUI returning deviceMetaData, resourceSlotsInRG, mergedResourceSlots, refresh, isLoading - Move ImageWithFallback from webui to BUI as BAIImageWithFallback - Fix BAIResourceNumberWithIcon to handle server-configured icons via SVG fallback - Move BAIMetaDataWrapper from DefaultProvidersForReactRoot to MainLayout (wraps only Outlet) - Update 16 webui components to import useResourceSlotsDetails from src/hooks/backendai - Remove duplicate useResourceSlotsDetails from BUI hooks/index.ts --- .claude/rules/use-memo-directive.md | 46 ++++++++++++ .github/instructions/react.instructions.md | 26 +++++-- packages/backend.ai-ui/.storybook/main.ts | 1 + .../src/components/BAIImageWithFallback.tsx | 35 +++++++++ .../BAIResourceNumberWithIcon.stories.tsx | 72 ++++++++++++++++++- .../components/BAIResourceNumberWithIcon.tsx | 27 +++++-- .../components/fragments/BAIAgentTable.tsx | 5 +- .../fragments/BAIProjectSettingModal.tsx | 5 +- .../backend.ai-ui/src/components/index.ts | 2 + .../BAIMetaDataProvider.tsx | 22 +++++- .../provider/BAIMetaDataProvider/context.ts | 12 +++- .../hooks/useBAIDeviceMetaData.ts | 21 ------ .../hooks/useBAIMetaData.ts | 29 ++++++++ .../provider/BAIMetaDataProvider/index.ts | 5 +- packages/backend.ai-ui/src/hooks/index.ts | 47 +----------- react/src/components/AgentDetailModal.tsx | 3 +- .../AgentNodeItems/AgentResources.tsx | 2 +- react/src/components/AgentStats.tsx | 3 +- react/src/components/AgentSummaryList.tsx | 4 +- .../SessionIdleChecks.tsx | 9 +-- .../SessionSlotCell.tsx | 13 ++-- react/src/components/DefaultProviders.tsx | 49 ++++++++----- .../ResourceGroupFairShareSettingModal.tsx | 2 +- .../ResourceGroupFairShareTable.tsx | 2 +- .../UsageBucketChartContent.tsx | 2 +- react/src/components/ImageWithFallback.tsx | 35 --------- .../KeypairResourcePolicySettingModal.tsx | 4 +- .../src/components/MainLayout/MainLayout.tsx | 9 ++- .../ManageImageResourceLimitModal.tsx | 3 +- react/src/components/MyResource.tsx | 4 +- .../MyResourceWithinResourceGroup.test.tsx | 25 ++++--- .../MyResourceWithinResourceGroup.tsx | 3 +- react/src/components/ResourceNumber.tsx | 7 +- .../components/ResourcePresetSettingModal.tsx | 3 +- .../ResourceAllocationFormItems.tsx | 6 +- react/src/components/SessionMetricGraph.tsx | 3 +- react/src/components/SessionUsageMonitor.tsx | 8 +-- .../TotalResourceWithinResourceGroup.tsx | 6 +- react/src/hooks/backendai.tsx | 39 ++++++++++ 39 files changed, 399 insertions(+), 200 deletions(-) create mode 100644 .claude/rules/use-memo-directive.md create mode 100644 packages/backend.ai-ui/src/components/BAIImageWithFallback.tsx delete mode 100644 packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/hooks/useBAIDeviceMetaData.ts create mode 100644 packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/hooks/useBAIMetaData.ts delete mode 100644 react/src/components/ImageWithFallback.tsx diff --git a/.claude/rules/use-memo-directive.md b/.claude/rules/use-memo-directive.md new file mode 100644 index 0000000000..893819d6fd --- /dev/null +++ b/.claude/rules/use-memo-directive.md @@ -0,0 +1,46 @@ +--- +description: When writing or editing React components or custom hooks +paths: + - "react/**/*.{tsx,ts}" + - "packages/backend.ai-ui/**/*.{tsx,ts}" +--- + +# 'use memo' Directive Rule + +Always place `'use memo'` at the very top of the function body for **both React components and custom hooks** (`use*` functions). + +## Why + +This project uses the React Compiler in annotation mode (`babel-plugin-react-compiler`). The `'use memo'` directive opts the function in to full compiler optimization — automatic memoization of values, callbacks, and JSX without manual `useMemo`/`useCallback`. Omitting it from hooks means the hook's internals are not optimized, even if the components consuming it are. + +## Pattern + +```tsx +// ✅ Component — always add 'use memo' +function MyComponent({ id }: Props) { + 'use memo'; + + return
{id}
; +} + +// ✅ Custom hook — always add 'use memo' +const useMyData = (id: string) => { + 'use memo'; + + return useQuery(id); +}; + +// ❌ Missing in hook — not optimized +const useMyData = (id: string) => { + return useQuery(id); +}; +``` + +## Rules + +1. Add `'use memo'` as the **first statement** in the function body — before any hooks, variables, or logic. +2. Apply to **both components and custom hooks** (`use*` naming convention). +3. Comments before the directive are allowed. +4. Use single or double quotes — **not backticks**. +5. **Never remove** an existing `'use memo'` directive. +6. Do not add `'use memo'` inside helper functions that are not components or hooks (plain utilities, event handlers, etc.). diff --git a/.github/instructions/react.instructions.md b/.github/instructions/react.instructions.md index 22090247ee..69251ad507 100644 --- a/.github/instructions/react.instructions.md +++ b/.github/instructions/react.instructions.md @@ -8,10 +8,10 @@ These instructions apply to React components in the `/react` directory. ## React Compiler Optimization -### 'use memo' Directive (Recommended) +### 'use memo' Directive (Required) - This project uses the new **React Compiler**. -- We actively use React directives such as `use memo` and `use client` at the top components (NOT files). +- We actively use React directives such as `use memo` and `use client` at the top of **component and custom hook** function bodies (NOT files). - `use memo` is **intentional and valid** in this codebase. - Even if tooling or TypeScript/ESLint shows something like: - `Unknown directive: 'use memo'.` @@ -20,8 +20,8 @@ These instructions apply to React components in the `/react` directory. ### What should do -- When generating or editing React components: - - Prefer adding `use memo` when it is beneficial for React Compiler. +- When generating or editing React components **or custom hooks** (`use*` functions): + - **Always** add `'use memo'` at the top of the function body. - **Never** remove existing `use memo` directives. - **Never** “fix” or “rename” `use memo` to something else. - **Never** add comments suggesting that `use memo` is unknown, invalid, or deprecated. @@ -37,7 +37,7 @@ The `'use memo'` directive has **strict placement requirements**: - Only the first directive is processed; additional directives are ignored ```typescript -// ✅ Good: 'use memo' at the very beginning of function body +// ✅ Good: 'use memo' at the very beginning of component body function MyComponent({ data }: Props) { 'use memo'; @@ -46,6 +46,14 @@ function MyComponent({ data }: Props) { return
{data}
; } +// ✅ Good: 'use memo' in custom hooks too +const useMyHook = (id: string) => { + 'use memo'; + + const [value, setValue] = useState(null); + return value; +}; + // ✅ Good: Comments before 'use memo' are OK const AnotherComponent: React.FC = ({ data }) => { // This component is optimized by React Compiler @@ -74,6 +82,12 @@ function BacktickBad({ data }: Props) { `use memo`; // ❌ Must use quotes, not backticks return
{data}
; } + +// ❌ Bad: Missing 'use memo' in a new custom hook +function useData(id: string) { + // ❌ Should have 'use memo' at the top + return useSomeQuery(id); +} ``` ### Manual Optimization Hooks (Use Sparingly) @@ -1056,7 +1070,7 @@ When reviewing React code, check for: ### React Compiler & Optimization -- [ ] Component uses `'use memo'` directive if it's a new component +- [ ] Component and custom hook (`use*`) uses `'use memo'` directive if new - [ ] No unnecessary `useMemo`/`useCallback` (prefer 'use memo' directive) - [ ] `useEffectEvent` is used for non-reactive logic in Effects when appropriate diff --git a/packages/backend.ai-ui/.storybook/main.ts b/packages/backend.ai-ui/.storybook/main.ts index ee77285a4a..39fc3cca1c 100644 --- a/packages/backend.ai-ui/.storybook/main.ts +++ b/packages/backend.ai-ui/.storybook/main.ts @@ -27,6 +27,7 @@ const config: StorybookConfig = { staticDirs: [ './public', { from: '../../../resources/fonts', to: '/fonts' }, + { from: '../../../resources/icons', to: '/resources/icons' }, ], }; export default config; diff --git a/packages/backend.ai-ui/src/components/BAIImageWithFallback.tsx b/packages/backend.ai-ui/src/components/BAIImageWithFallback.tsx new file mode 100644 index 0000000000..3d803f191e --- /dev/null +++ b/packages/backend.ai-ui/src/components/BAIImageWithFallback.tsx @@ -0,0 +1,35 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import React, { useState } from 'react'; + +export interface BAIImageWithFallbackProps extends Omit< + React.ImgHTMLAttributes, + 'onError' +> { + src: string; + fallbackIcon: React.ReactNode; + alt: string; +} + +const BAIImageWithFallback: React.FC = ({ + src, + fallbackIcon, + alt, + ...props +}) => { + 'use memo'; + const [errorSrc, setErrorSrc] = useState(null); + const hasError = errorSrc === src; + + if (hasError) { + return <>{fallbackIcon}; + } + + return ( + {alt} setErrorSrc(src)} /> + ); +}; + +export default BAIImageWithFallback; diff --git a/packages/backend.ai-ui/src/components/BAIResourceNumberWithIcon.stories.tsx b/packages/backend.ai-ui/src/components/BAIResourceNumberWithIcon.stories.tsx index 842bb33561..00cb76b31f 100644 --- a/packages/backend.ai-ui/src/components/BAIResourceNumberWithIcon.stories.tsx +++ b/packages/backend.ai-ui/src/components/BAIResourceNumberWithIcon.stories.tsx @@ -168,7 +168,10 @@ const meta: Meta = { }, decorators: [ (Story) => ( - + ), @@ -395,6 +398,73 @@ export const WithSharedMemory: Story = { ), }; +export const ServerConfiguredIcon: Story = { + parameters: { + docs: { + description: { + story: ` +Demonstrates how the component handles devices whose \`display_icon\` is **not** in the built-in \`knownDeviceIcons\` set +(\`nvidia\`, \`rocm\`, \`tpu\`, \`ipu\`, \`gaudi\`, \`furiosa\`, \`rebel\`, \`tenstorrent\`). + +**Icon resolution order for unknown devices:** +1. \`display_icon\` is set but not in built-in set → fetches \`/resources/icons/{display_icon}.svg\` from the server +2. SVG file missing (404) → falls back to \`MicrochipIcon\` +3. \`display_icon\` not set at all → falls back to \`MicrochipIcon\` + +> **Note:** The "SVG from server" row uses \`npu_generic.svg\` as a stand-in example +> since it already exists in \`/resources/icons/\` and is not part of \`knownDeviceIcons\`. +> In production, any server-configured accelerator with a custom \`display_icon\` name follows the same path.`, + }, + }, + }, + decorators: [ + (Story) => { + const extendedDeviceMetaData = { + ...mockDeviceMetaData, + // display_icon set to a name that exists in /resources/icons/ but is + // NOT in knownDeviceIcons → loads /resources/icons/npu_generic.svg + 'npu-generic.device': { + slot_name: 'npu-generic.device', + description: 'Generic NPU (server-configured icon)', + human_readable_name: 'NPU', + display_unit: 'NPU', + number_format: { binary: false, round_length: 0 }, + display_icon: 'npu_generic', + }, + // display_icon set to a name that does NOT exist → fallback to MicrochipIcon + 'unknown.device': { + slot_name: 'unknown.device', + description: 'Unknown device (missing icon file)', + human_readable_name: 'Unknown', + display_unit: 'Unit', + number_format: { binary: false, round_length: 0 }, + display_icon: 'nonexistent_icon', + }, + }; + return ( + + + + ); + }, + ], + render: () => ( + + + SVG from server (npu_generic.svg): + + + + Fallback (missing SVG file): + + + + ), +}; + export const WithoutTooltip: Story = { parameters: { docs: { diff --git a/packages/backend.ai-ui/src/components/BAIResourceNumberWithIcon.tsx b/packages/backend.ai-ui/src/components/BAIResourceNumberWithIcon.tsx index da917630f5..eba1c9f799 100644 --- a/packages/backend.ai-ui/src/components/BAIResourceNumberWithIcon.tsx +++ b/packages/backend.ai-ui/src/components/BAIResourceNumberWithIcon.tsx @@ -8,9 +8,10 @@ import BAIRocmIcon from '../icons/BAIRocmIcon'; import BAITenstorrentIcon from '../icons/BAITenstorrentIcon'; import BAITpuIcon from '../icons/BAITpuIcon'; import BAIFlex from './BAIFlex'; +import BAIImageWithFallback from './BAIImageWithFallback'; import NumberWithUnit from './BAINumberWithUnit'; import BAIText from './BAIText'; -import { ResourceSlotName, useBAIDeviceMetaData } from './provider'; +import { ResourceSlotName, useBAIMetaData } from './provider'; import { theme, Tooltip, TooltipProps } from 'antd'; import * as _ from 'lodash-es'; import { CpuIcon, MemoryStickIcon, MicrochipIcon } from 'lucide-react'; @@ -50,7 +51,7 @@ const BAIResourceNumberWithIcon = ({ }: BAIResourceNumberWithIconProps) => { 'use memo'; - const deviceMetaData = useBAIDeviceMetaData(); + const { mergedResourceSlots: deviceMetaData } = useBAIMetaData(); const { token } = theme.useToken(); const formatAmount = (amount: string) => { @@ -137,7 +138,8 @@ export const ResourceTypeIcon = ({ }: ResourceTypeIconProps) => { 'use memo'; - const deviceMetaData = useBAIDeviceMetaData(); + const { mergedResourceSlots: deviceMetaData } = useBAIMetaData(); + const displayIcon = deviceMetaData[type]?.display_icon; const getIconContent = () => { if (type === 'cpu') { @@ -155,14 +157,29 @@ export const ResourceTypeIcon = ({ ); } - const displayIcon = deviceMetaData[type]?.display_icon; - if (displayIcon && _.keys(knownDeviceIcons).includes(displayIcon)) { return ( knownDeviceIcons[displayIcon as keyof typeof knownDeviceIcons] ?? null ); } + if (displayIcon) { + return ( + + + + } + /> + ); + } + return ( diff --git a/packages/backend.ai-ui/src/components/fragments/BAIAgentTable.tsx b/packages/backend.ai-ui/src/components/fragments/BAIAgentTable.tsx index 643880da64..69a2200af6 100644 --- a/packages/backend.ai-ui/src/components/fragments/BAIAgentTable.tsx +++ b/packages/backend.ai-ui/src/components/fragments/BAIAgentTable.tsx @@ -8,7 +8,7 @@ import { convertUnitValue, toFixedFloorWithoutTrailingZeros, } from '../../helper'; -import { useResourceSlotsDetails } from '../../hooks'; +import { useBAIMetaData } from '../../hooks'; import BAIDoubleTag from '../BAIDoubleTag'; import BAIFlex from '../BAIFlex'; import BAIIntervalView from '../BAIIntervalView'; @@ -68,11 +68,12 @@ const BAIAgentTable: React.FC = ({ customizeColumns, ...tableProps }) => { + 'use memo'; const { t } = useTranslation(); const { token } = theme.useToken(); const baiClient = useConnectedBAIClient(); - const { mergedResourceSlots } = useResourceSlotsDetails(); + const { mergedResourceSlots } = useBAIMetaData(); const agents = useFragment( graphql` diff --git a/packages/backend.ai-ui/src/components/fragments/BAIProjectSettingModal.tsx b/packages/backend.ai-ui/src/components/fragments/BAIProjectSettingModal.tsx index 341768203a..85b66903e2 100644 --- a/packages/backend.ai-ui/src/components/fragments/BAIProjectSettingModal.tsx +++ b/packages/backend.ai-ui/src/components/fragments/BAIProjectSettingModal.tsx @@ -14,7 +14,7 @@ import { BAIProjectSettingModalFragment$key } from '../../__generated__/BAIProje import { BAIProjectSettingModalModifyMutation } from '../../__generated__/BAIProjectSettingModalModifyMutation.graphql'; import { BAIProjectSettingModalQuery } from '../../__generated__/BAIProjectSettingModalQuery.graphql'; import { convertToBinaryUnit } from '../../helper'; -import { useErrorMessageResolver, useResourceSlotsDetails } from '../../hooks'; +import { useErrorMessageResolver, useBAIMetaData } from '../../hooks'; import BAIModal, { BAIModalProps } from '../BAIModal'; import { App, @@ -66,10 +66,11 @@ const BAIProjectSettingModal = ({ projectFragment, ...modalProps }: BAIProjectSettingModalProps) => { + 'use memo'; const { token } = theme.useToken(); const { t } = useTranslation(); const deferredOpen = useDeferredValue(modalProps.open); - const { resourceSlotsInRG, deviceMetaData } = useResourceSlotsDetails(); + const { resourceSlotsInRG, deviceMetaData } = useBAIMetaData(); const form = useRef>(null); const { message } = App.useApp(); const { getErrorMessage } = useErrorMessageResolver(); diff --git a/packages/backend.ai-ui/src/components/index.ts b/packages/backend.ai-ui/src/components/index.ts index dc54f4737a..e214a6df5a 100644 --- a/packages/backend.ai-ui/src/components/index.ts +++ b/packages/backend.ai-ui/src/components/index.ts @@ -1,3 +1,5 @@ +export { default as BAIImageWithFallback } from './BAIImageWithFallback'; +export type { BAIImageWithFallbackProps } from './BAIImageWithFallback'; export { default as BAIBadge } from './BAIBadge'; export type { BAIBadgeProps } from './BAIBadge'; export { default as BAIBoardItemTitle } from './BAIBoardItemTitle'; diff --git a/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/BAIMetaDataProvider.tsx b/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/BAIMetaDataProvider.tsx index 02aaa2b4dd..b6afc4bc34 100644 --- a/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/BAIMetaDataProvider.tsx +++ b/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/BAIMetaDataProvider.tsx @@ -1,20 +1,36 @@ -import { BAIDeviceMetaDataContext } from './context'; +import { BAIMetaDataContext, BAIMetaDataContextValue } from './context'; import type { DeviceMetaData } from './types'; import type { ReactNode } from 'react'; export interface BAIMetaDataProviderProps { deviceMetaData?: DeviceMetaData; + resourceSlotsInRG?: DeviceMetaData; + mergedResourceSlots?: DeviceMetaData; + refresh?: () => void; + isLoading?: boolean; children?: ReactNode; } const BAIMetaDataProvider = ({ deviceMetaData, + resourceSlotsInRG, + mergedResourceSlots, + refresh = () => {}, + isLoading = false, children, }: BAIMetaDataProviderProps) => { + 'use memo'; + const value: BAIMetaDataContextValue = { + deviceMetaData, + resourceSlotsInRG, + mergedResourceSlots, + refresh, + isLoading, + }; return ( - + {children} - + ); }; diff --git a/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/context.ts b/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/context.ts index 5bc1a73c3b..ce41bc3be9 100644 --- a/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/context.ts +++ b/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/context.ts @@ -1,6 +1,14 @@ import type { DeviceMetaData } from './types'; import { createContext } from 'react'; -export const BAIDeviceMetaDataContext = createContext< - DeviceMetaData | undefined +export type BAIMetaDataContextValue = { + deviceMetaData: DeviceMetaData | undefined; + resourceSlotsInRG: DeviceMetaData | undefined; + mergedResourceSlots: DeviceMetaData | undefined; + refresh: () => void; + isLoading: boolean; +}; + +export const BAIMetaDataContext = createContext< + BAIMetaDataContextValue | undefined >(undefined); diff --git a/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/hooks/useBAIDeviceMetaData.ts b/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/hooks/useBAIDeviceMetaData.ts deleted file mode 100644 index 37367c8b1c..0000000000 --- a/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/hooks/useBAIDeviceMetaData.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useBAILogger } from '../../../../hooks'; -import { BAIDeviceMetaDataContext } from '../context'; -import { useContext } from 'react'; - -const useBAIDeviceMetaData = () => { - const { logger } = useBAILogger(); - try { - const context = useContext(BAIDeviceMetaDataContext); - if (!context) { - throw new Error( - 'useBAIDeviceMetaData must be used within a BAIMetaDataProvider', - ); - } - return context; - } catch (error) { - logger.error('Error using BAI Device MetaData:', error); - throw error; - } -}; - -export default useBAIDeviceMetaData; diff --git a/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/hooks/useBAIMetaData.ts b/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/hooks/useBAIMetaData.ts new file mode 100644 index 0000000000..ec1cad683d --- /dev/null +++ b/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/hooks/useBAIMetaData.ts @@ -0,0 +1,29 @@ +import { BAIMetaDataContext } from '../context'; +import { useContext } from 'react'; + +/** + * Returns all resource slot metadata from the BAIMetaDataProvider context. + * - `deviceMetaData`: raw static device metadata from `resources/device_metadata.json` + * - `resourceSlotsInRG`: server-side resource slots scoped to the current resource group only + * when the provider was created with a resource group name; otherwise this may contain + * unscoped or all-group resource slot data + * - `mergedResourceSlots`: merged result of the above two sources (always a defined object) + * - `refresh`: re-fetches resource slot details + * - `isLoading`: whether the resource slot details are currently loading + */ +const useBAIMetaData = () => { + 'use memo'; + const context = useContext(BAIMetaDataContext); + if (!context) { + throw new Error('useBAIMetaData must be used within a BAIMetaDataProvider'); + } + return { + deviceMetaData: context.deviceMetaData, + resourceSlotsInRG: context.resourceSlotsInRG, + mergedResourceSlots: context.mergedResourceSlots ?? {}, + refresh: context.refresh, + isLoading: context.isLoading, + }; +}; + +export default useBAIMetaData; diff --git a/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/index.ts b/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/index.ts index 9e90103768..bd95a90f5d 100644 --- a/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/index.ts +++ b/packages/backend.ai-ui/src/components/provider/BAIMetaDataProvider/index.ts @@ -1,5 +1,6 @@ export { default as BAIMetaDataProvider } from './BAIMetaDataProvider'; export type { BAIMetaDataProviderProps } from './BAIMetaDataProvider'; -export { BAIDeviceMetaDataContext } from './context'; -export { default as useBAIDeviceMetaData } from './hooks/useBAIDeviceMetaData'; +export { BAIMetaDataContext } from './context'; +export type { BAIMetaDataContextValue } from './context'; +export { default as useBAIMetaData } from './hooks/useBAIMetaData'; export type * from './types'; diff --git a/packages/backend.ai-ui/src/hooks/index.ts b/packages/backend.ai-ui/src/hooks/index.ts index 6bf475e1f5..bcfbb5a74b 100644 --- a/packages/backend.ai-ui/src/hooks/index.ts +++ b/packages/backend.ai-ui/src/hooks/index.ts @@ -1,10 +1,5 @@ -import { - ResourceSlotName, - useBAIDeviceMetaData, - useConnectedBAIClient, -} from '../components'; -import { useSuspenseTanQuery, useTanQuery } from '../helper/reactQueryAlias'; -import { useBAISignedRequestWithPromise } from './useBAISignedRequestWithPromise'; +import { useConnectedBAIClient } from '../components'; +import { useSuspenseTanQuery } from '../helper/reactQueryAlias'; import { useEventNotStable } from './useEventNotStable'; import * as _ from 'lodash-es'; import { useMemo, useState } from 'react'; @@ -90,43 +85,6 @@ export type ResourceSlotDetail = { display_icon: string; }; -/** - * Custom hook to fetch resource slot details by resource group name. - * @param resourceGroupName - The name of the resource group. if not provided, it will use resource/device_metadata.json - * @returns An array containing the resource slots and a refresh function. - */ -export const useResourceSlotsDetails = (resourceGroupName?: string) => { - 'use memo'; - const [key, checkUpdate] = useUpdatableState('first'); - const baiRequestWithPromise = useBAISignedRequestWithPromise(); - const { data: resourceSlotsInRG, isLoading } = useTanQuery<{ - [key in ResourceSlotName]?: ResourceSlotDetail | undefined; - }>({ - queryKey: ['useResourceSlots', resourceGroupName, key], - queryFn: () => { - const search = new URLSearchParams(); - resourceGroupName && search.set('sgroup', resourceGroupName); - const searchParamString = search.toString(); - return baiRequestWithPromise({ - method: 'GET', - // if `sgroup` is not provided, it will return all resource slots of all resource groups - url: `/config/resource-slots/details${searchParamString ? '?' + search.toString() : ''}`, - }); - }, - staleTime: 3000, - }); - - const deviceMetaData = useBAIDeviceMetaData(); - - return { - resourceSlotsInRG, - deviceMetaData, - mergedResourceSlots: _.merge({}, deviceMetaData, resourceSlotsInRG), - refresh: checkUpdate, - isLoading, - }; -}; - export function useMutationWithPromise( mutation: GraphQLTaggedNode, ) { @@ -170,3 +128,4 @@ export type { LoggerPlugin, LogContext, BAILogger } from './useBAILogger'; export { useEventNotStable } from './useEventNotStable'; export { useProjectResourceGroups } from './useProjectResourceGroups'; export type { ScalingGroupItem } from './useProjectResourceGroups'; +export { useBAIMetaData } from '../components'; diff --git a/react/src/components/AgentDetailModal.tsx b/react/src/components/AgentDetailModal.tsx index 057e7384c2..c9235e3723 100644 --- a/react/src/components/AgentDetailModal.tsx +++ b/react/src/components/AgentDetailModal.tsx @@ -7,9 +7,9 @@ import { convertToDecimalUnit, toFixedFloorWithoutTrailingZeros, } from '../helper'; +import { useResourceSlotsDetails } from '../hooks/backendai'; import { Col, Row, theme, Typography } from 'antd'; import { - useResourceSlotsDetails, BAIFlex, BAIModal, BAIModalProps, @@ -40,6 +40,7 @@ const AgentDetailModal: React.FC = ({ onRequestClose, ...modalProps }) => { + 'use memo'; const { t } = useTranslation(); const { token } = theme.useToken(); const { mergedResourceSlots } = useResourceSlotsDetails(); diff --git a/react/src/components/AgentNodeItems/AgentResources.tsx b/react/src/components/AgentNodeItems/AgentResources.tsx index f2527b9426..87840f096c 100644 --- a/react/src/components/AgentNodeItems/AgentResources.tsx +++ b/react/src/components/AgentNodeItems/AgentResources.tsx @@ -2,6 +2,7 @@ @license Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ +import { useResourceSlotsDetails } from '../../hooks/backendai'; import AgentDetailModal from '../AgentDetailModal'; import SimpleProgressWithLabel from '../SimpleProgressWithLabel'; import { InfoCircleOutlined } from '@ant-design/icons'; @@ -16,7 +17,6 @@ import { ResourceSlotName, ResourceTypeIcon, toFixedFloorWithoutTrailingZeros, - useResourceSlotsDetails, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; import { useState } from 'react'; diff --git a/react/src/components/AgentStats.tsx b/react/src/components/AgentStats.tsx index 709c35ab7f..f827987d45 100644 --- a/react/src/components/AgentStats.tsx +++ b/react/src/components/AgentStats.tsx @@ -2,10 +2,10 @@ @license Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ +import { useResourceSlotsDetails } from '../hooks/backendai'; import { useControllableValue } from 'ahooks'; import { Segmented, Skeleton, theme, Typography } from 'antd'; import { - useResourceSlotsDetails, BAIBoardItemTitle, BAIFetchKeyButton, BAIFlex, @@ -34,6 +34,7 @@ const AgentStats: React.FC = ({ extra, ...props }) => { + 'use memo'; const { t } = useTranslation(); const { token } = theme.useToken(); diff --git a/react/src/components/AgentSummaryList.tsx b/react/src/components/AgentSummaryList.tsx index 8263b032c0..dee1c3686b 100644 --- a/react/src/components/AgentSummaryList.tsx +++ b/react/src/components/AgentSummaryList.tsx @@ -11,7 +11,7 @@ import { convertToBinaryUnit, toFixedFloorWithoutTrailingZeros, } from '../helper'; -import { ResourceSlotName } from '../hooks/backendai'; +import { ResourceSlotName, useResourceSlotsDetails } from '../hooks/backendai'; import { useBAIPaginationOptionStateOnSearchParamLegacy } from '../hooks/reactPaginationQueryOptions'; import { useResourceGroupsForCurrentProject } from '../hooks/useCurrentProject'; import { useHiddenColumnKeysSetting } from '../hooks/useHiddenColumnKeysSetting'; @@ -33,7 +33,6 @@ import { BAIPropertyFilter, mergeFilterValues, ResourceTypeIcon, - useResourceSlotsDetails, BAIProgressWithLabel, useFetchKey, INITIAL_FETCH_KEY, @@ -58,6 +57,7 @@ const AgentSummaryList: React.FC = ({ containerStyle, tableProps, }) => { + 'use memo'; const { t } = useTranslation(); const { token } = theme.useToken(); const { mergedResourceSlots } = useResourceSlotsDetails(); diff --git a/react/src/components/ComputeSessionNodeItems/SessionIdleChecks.tsx b/react/src/components/ComputeSessionNodeItems/SessionIdleChecks.tsx index 75ae5e7c36..0fb3293c4f 100644 --- a/react/src/components/ComputeSessionNodeItems/SessionIdleChecks.tsx +++ b/react/src/components/ComputeSessionNodeItems/SessionIdleChecks.tsx @@ -8,14 +8,10 @@ import { formatDurationAsDays, toFixedFloorWithoutTrailingZeros, } from '../../helper'; +import { useResourceSlotsDetails } from '../../hooks/backendai'; import { InfoCircleOutlined } from '@ant-design/icons'; import { Tooltip, Typography, theme } from 'antd'; -import { - useResourceSlotsDetails, - useMemoizedJSONParse, - BAIFlex, - BAIDoubleTag, -} from 'backend.ai-ui'; +import { useMemoizedJSONParse, BAIFlex, BAIDoubleTag } from 'backend.ai-ui'; import * as _ from 'lodash-es'; import { useTranslation } from 'react-i18next'; import { graphql, useFragment, useLazyLoadQuery } from 'react-relay'; @@ -132,6 +128,7 @@ const SessionIdleChecks: React.FC = ({ direction = 'row', fetchKeyForLegacyLoadQuery, }) => { + 'use memo'; const { t } = useTranslation(); const { token } = theme.useToken(); const { mergedResourceSlots } = useResourceSlotsDetails(); diff --git a/react/src/components/ComputeSessionNodeItems/SessionSlotCell.tsx b/react/src/components/ComputeSessionNodeItems/SessionSlotCell.tsx index c97a1f8389..5d40f277e1 100644 --- a/react/src/components/ComputeSessionNodeItems/SessionSlotCell.tsx +++ b/react/src/components/ComputeSessionNodeItems/SessionSlotCell.tsx @@ -4,17 +4,15 @@ */ import { SessionSlotCellFragment$key } from '../../__generated__/SessionSlotCellFragment.graphql'; import { convertToBinaryUnit } from '../../helper'; -import { ResourceSlotName } from '../../hooks/backendai'; +import { + ResourceSlotName, + useResourceSlotsDetails, +} from '../../hooks/backendai'; import { useSessionLiveStat } from '../../hooks/useSessionNodeLiveStat'; import { displayMemoryUsage } from '../SessionUsageMonitor'; import { Divider, Tooltip, TooltipProps, Typography } from 'antd'; import type { SemanticColor } from 'backend.ai-ui'; -import { - BAIBadge, - BAIBadgeProps, - BAIFlex, - useResourceSlotsDetails, -} from 'backend.ai-ui'; +import { BAIBadge, BAIBadgeProps, BAIFlex } from 'backend.ai-ui'; import * as _ from 'lodash-es'; import React from 'react'; import { graphql, useFragment } from 'react-relay'; @@ -27,6 +25,7 @@ const SessionSlotCell: React.FC = ({ type, sessionFrgmt, }) => { + 'use memo'; const { mergedResourceSlots } = useResourceSlotsDetails(''); const session = useFragment( graphql` diff --git a/react/src/components/DefaultProviders.tsx b/react/src/components/DefaultProviders.tsx index 9754f0f16a..7bdd4f9b40 100644 --- a/react/src/components/DefaultProviders.tsx +++ b/react/src/components/DefaultProviders.tsx @@ -59,7 +59,7 @@ import React, { import { useTranslation, initReactI18next } from 'react-i18next'; import { RelayEnvironmentProvider } from 'react-relay/hooks'; import { useLocation, useNavigate } from 'react-router-dom'; -import { useDeviceMetaData } from 'src/hooks/backendai'; +import { useResourceSlotsDetails } from 'src/hooks/backendai'; import { QueryParamProvider } from 'use-query-params'; import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6'; @@ -176,11 +176,26 @@ const commonAppProps: AppProps = { }, }; -const BAIMetaDataWrapper = ({ children }: { children: ReactNode }) => { - const { data } = useDeviceMetaData(); +export const BAIMetaDataWrapper = ({ children }: { children: ReactNode }) => { + 'use memo'; + const { + deviceMetaData, + resourceSlotsInRG, + mergedResourceSlots, + refresh, + isLoading, + } = useResourceSlotsDetails(); return ( - {children} + + {children} + ); }; @@ -280,20 +295,18 @@ export const DefaultProvidersForReactRoot: React.FC<{ variant: 'outlined', }} > - - - - {/* */} - - {/* */} - {/* */} - {children} - {/* */} - - {/* */} - - - + + + {/* */} + + {/* */} + {/* */} + {children} + {/* */} + + {/* */} + + diff --git a/react/src/components/FairShareItems/ResourceGroupFairShareSettingModal.tsx b/react/src/components/FairShareItems/ResourceGroupFairShareSettingModal.tsx index 7548ac079a..f903355375 100644 --- a/react/src/components/FairShareItems/ResourceGroupFairShareSettingModal.tsx +++ b/react/src/components/FairShareItems/ResourceGroupFairShareSettingModal.tsx @@ -2,6 +2,7 @@ @license Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ +import { useResourceSlotsDetails } from '../../hooks/backendai'; import QuestionIconWithTooltip from '../QuestionIconWithTooltip'; import { App, Col, Form, Input, InputNumber, Row, theme } from 'antd'; import { FormInstance } from 'antd/lib'; @@ -12,7 +13,6 @@ import { BAIModal, BAIModalProps, useBAILogger, - useResourceSlotsDetails, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; import { useRef } from 'react'; diff --git a/react/src/components/FairShareItems/ResourceGroupFairShareTable.tsx b/react/src/components/FairShareItems/ResourceGroupFairShareTable.tsx index 595469e5cf..59f7678cea 100644 --- a/react/src/components/FairShareItems/ResourceGroupFairShareTable.tsx +++ b/react/src/components/FairShareItems/ResourceGroupFairShareTable.tsx @@ -2,6 +2,7 @@ @license Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ +import { useResourceSlotsDetails } from '../../hooks/backendai'; import QuestionIconWithTooltip from '../QuestionIconWithTooltip'; import ResourceGroupFairShareSettingModal from './ResourceGroupFairShareSettingModal'; import { SettingOutlined } from '@ant-design/icons'; @@ -15,7 +16,6 @@ import { BAIUnmountAfterClose, convertToBinaryUnit, ResourceTypeIcon, - useResourceSlotsDetails, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; import { parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'; diff --git a/react/src/components/FairShareItems/UsageBucketChartContent.tsx b/react/src/components/FairShareItems/UsageBucketChartContent.tsx index 1008e620bd..d64c3ae385 100644 --- a/react/src/components/FairShareItems/UsageBucketChartContent.tsx +++ b/react/src/components/FairShareItems/UsageBucketChartContent.tsx @@ -2,13 +2,13 @@ @license Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ +import { useResourceSlotsDetails } from '../../hooks/backendai'; import { presetPalettes } from '@ant-design/colors'; import { Empty, Tabs, Typography, theme } from 'antd'; import { createStyles } from 'antd-style'; import { convertToBinaryUnit, INITIAL_FETCH_KEY, - useResourceSlotsDetails, toFixedFloorWithoutTrailingZeros, BAIFlex, } from 'backend.ai-ui'; diff --git a/react/src/components/ImageWithFallback.tsx b/react/src/components/ImageWithFallback.tsx deleted file mode 100644 index 9b5a00bbba..0000000000 --- a/react/src/components/ImageWithFallback.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** - @license - Copyright (c) 2015-2026 Lablup Inc. All rights reserved. - */ -import React, { useState } from 'react'; - -interface ImageWithFallbackProps extends Omit< - React.ImgHTMLAttributes, - 'onError' -> { - src: string; - fallbackIcon: React.ReactNode; - alt: string; -} - -const ImageWithFallback: React.FC = ({ - src, - fallbackIcon: fallback, - alt, - ...props -}) => { - const [hasError, setHasError] = useState(false); - - const handleError = () => { - setHasError(true); - }; - - if (hasError) { - return <>{fallback}; - } - - return {alt}; -}; - -export default ImageWithFallback; diff --git a/react/src/components/KeypairResourcePolicySettingModal.tsx b/react/src/components/KeypairResourcePolicySettingModal.tsx index f670244bd3..d86f573ec9 100644 --- a/react/src/components/KeypairResourcePolicySettingModal.tsx +++ b/react/src/components/KeypairResourcePolicySettingModal.tsx @@ -14,7 +14,7 @@ import { import { convertToBinaryUnit } from '../helper'; import { MAX_CPU_QUOTA, SIGNED_32BIT_MAX_INT } from '../helper/const-vars'; import { useSuspendedBackendaiClient } from '../hooks'; -import { useResourceSlots } from '../hooks/backendai'; +import { useResourceSlots, useResourceSlotsDetails } from '../hooks/backendai'; import FormItemWithUnlimited from './FormItemWithUnlimited'; import { QuestionCircleOutlined } from '@ant-design/icons'; import { @@ -37,7 +37,6 @@ import { BAIFlex, BAIModal, BAIModalProps, - useResourceSlotsDetails, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; import React, { useMemo, useRef } from 'react'; @@ -63,6 +62,7 @@ const KeypairResourcePolicySettingModal: React.FC< onRequestClose, ...props }) => { + 'use memo'; const { t } = useTranslation(); const { message } = App.useApp(); const { token } = theme.useToken(); diff --git a/react/src/components/MainLayout/MainLayout.tsx b/react/src/components/MainLayout/MainLayout.tsx index 9d9b91093a..4500c5c585 100644 --- a/react/src/components/MainLayout/MainLayout.tsx +++ b/react/src/components/MainLayout/MainLayout.tsx @@ -11,6 +11,7 @@ import Page401 from '../../pages/Page401'; import Page404 from '../../pages/Page404'; import BAIContentWithDrawerArea from '../BAIContentWithDrawerArea'; import BAIErrorBoundary from '../BAIErrorBoundary'; +import { BAIMetaDataWrapper } from '../DefaultProviders'; import ErrorBoundaryWithNullFallback from '../ErrorBoundaryWithNullFallback'; import ForceTOTPChecker from '../ForceTOTPChecker'; import NetworkStatusBanner from '../NetworkStatusBanner'; @@ -270,9 +271,11 @@ function MainLayout() { - - - + + + + + diff --git a/react/src/components/ManageImageResourceLimitModal.tsx b/react/src/components/ManageImageResourceLimitModal.tsx index 442e514a3f..b93c8a4d74 100644 --- a/react/src/components/ManageImageResourceLimitModal.tsx +++ b/react/src/components/ManageImageResourceLimitModal.tsx @@ -8,9 +8,9 @@ import { } from '../__generated__/ManageImageResourceLimitModalMutation.graphql'; import { ManageImageResourceLimitModal_image$key } from '../__generated__/ManageImageResourceLimitModal_image.graphql'; import { compareNumberWithUnits } from '../helper'; +import { useResourceSlotsDetails } from '../hooks/backendai'; import { Form, type FormInstance, message, InputNumber, Row, Col } from 'antd'; import { - useResourceSlotsDetails, BAIModal, BAIModalProps, BAIDynamicUnitInputNumber, @@ -33,6 +33,7 @@ interface ManageImageResourceLimitModalProps extends BAIModalProps { const ManageImageResourceLimitModal: React.FC< ManageImageResourceLimitModalProps > = ({ imageFrgmt, open, onRequestClose, ...BAIModalProps }) => { + 'use memo'; // Differentiate default max value based on manager version. // The difference between validating a variable type as undefined or none for an unsupplied field value. // [Associated PR links] : https://github.com/lablup/backend.ai/pull/1941 diff --git a/react/src/components/MyResource.tsx b/react/src/components/MyResource.tsx index 70f5ec0538..610f8800ab 100644 --- a/react/src/components/MyResource.tsx +++ b/react/src/components/MyResource.tsx @@ -2,7 +2,7 @@ @license Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ -import { ResourceSlotName } from '../hooks/backendai'; +import { ResourceSlotName, useResourceSlotsDetails } from '../hooks/backendai'; import { useCurrentProjectValue } from '../hooks/useCurrentProject'; import { useResourceLimitAndRemaining } from '../hooks/useResourceLimitAndRemaining'; import { Segmented, theme } from 'antd'; @@ -14,7 +14,6 @@ import { convertToNumber, processMemoryValue, BAIFetchKeyButton, - useResourceSlotsDetails, useFetchKey, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; @@ -33,6 +32,7 @@ const MyResource: React.FC = ({ extra, ...props }) => { + 'use memo'; const { t } = useTranslation(); const { token } = theme.useToken(); const currentProject = useCurrentProjectValue(); diff --git a/react/src/components/MyResourceWithinResourceGroup.test.tsx b/react/src/components/MyResourceWithinResourceGroup.test.tsx index 6ec5c3fc96..f877dcbfd4 100644 --- a/react/src/components/MyResourceWithinResourceGroup.test.tsx +++ b/react/src/components/MyResourceWithinResourceGroup.test.tsx @@ -135,20 +135,23 @@ jest.mock('../hooks/useResourceLimitAndRemaining', () => ({ ]), })); +jest.mock('../hooks/backendai', () => ({ + useResourceSlotsDetails: () => ({ + isLoading: false, + resourceSlotsInRG: { + cpu: { human_readable_name: 'CPU', display_unit: 'Core' }, + mem: { human_readable_name: 'Memory', display_unit: 'GiB' }, + 'cuda.device': { + human_readable_name: 'CUDA GPU', + display_unit: 'GPU', + }, + }, + }), +})); + jest.mock('backend.ai-ui', () => { const isoDate = new Date().toISOString(); return { - useResourceSlotsDetails: () => ({ - isLoading: false, - resourceSlotsInRG: { - cpu: { human_readable_name: 'CPU', display_unit: 'Core' }, - mem: { human_readable_name: 'Memory', display_unit: 'GiB' }, - 'cuda.device': { - human_readable_name: 'CUDA GPU', - display_unit: 'GPU', - }, - }, - }), useFetchKey: () => [isoDate, jest.fn(), isoDate], convertToNumber: (value: any) => parseFloat(value) || 0, processMemoryValue: (value: any) => { diff --git a/react/src/components/MyResourceWithinResourceGroup.tsx b/react/src/components/MyResourceWithinResourceGroup.tsx index 9e23786f4c..b39319e677 100644 --- a/react/src/components/MyResourceWithinResourceGroup.tsx +++ b/react/src/components/MyResourceWithinResourceGroup.tsx @@ -2,7 +2,7 @@ @license Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ -import { ResourceSlotName } from '../hooks/backendai'; +import { ResourceSlotName, useResourceSlotsDetails } from '../hooks/backendai'; import { useCurrentProjectValue, useCurrentResourceGroupValue, @@ -19,7 +19,6 @@ import { processMemoryValue, BAIFetchKeyButton, BAIFlexProps, - useResourceSlotsDetails, useFetchKey, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; diff --git a/react/src/components/ResourceNumber.tsx b/react/src/components/ResourceNumber.tsx index e080a1c0e1..5553efcc5f 100644 --- a/react/src/components/ResourceNumber.tsx +++ b/react/src/components/ResourceNumber.tsx @@ -3,14 +3,13 @@ Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ import { convertToBinaryUnit } from '../helper'; -import { ResourceSlotName } from '../hooks/backendai'; +import { ResourceSlotName, useResourceSlotsDetails } from '../hooks/backendai'; import { useCurrentResourceGroupValue } from '../hooks/useCurrentProject'; -import ImageWithFallback from './ImageWithFallback'; import { Tooltip, Typography, theme } from 'antd'; import { BAIFlex, + BAIImageWithFallback, BAINumberWithUnit, - useResourceSlotsDetails, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; import { CpuIcon, MemoryStickIcon, MicrochipIcon } from 'lucide-react'; @@ -140,7 +139,7 @@ export const ResourceTypeIcon: React.FC = ({ const displayIcon = mergedResourceSlots[type]?.display_icon; if (displayIcon) { return ( - { }); }; +/** + * Fetches resource slot details from the server API and merges them with + * static device metadata. When `resourceGroupName` is provided, the API + * call is scoped to that specific resource group (for per-group accelerator availability). + * + * This is the webui-side hook that performs actual API calls. BUI components + * read the merged results from context (populated by BAIMetaDataWrapper). + */ +export const useResourceSlotsDetails = (resourceGroupName?: string) => { + 'use memo'; + const [key, checkUpdate] = useUpdatableState('first'); + const baiRequestWithPromise = useBAISignedRequestWithPromise(); + const { data: deviceMetaData } = useDeviceMetaData(); + const { data: resourceSlotsInRG, isLoading } = useTanQuery<{ + [key: string]: ResourceSlotDetail | undefined; + }>({ + queryKey: ['useResourceSlots', resourceGroupName, key], + queryFn: () => { + const search = new URLSearchParams(); + resourceGroupName && search.set('sgroup', resourceGroupName); + const searchParamString = search.toString(); + return baiRequestWithPromise({ + method: 'GET', + url: `/config/resource-slots/details${searchParamString ? '?' + search.toString() : ''}`, + }); + }, + staleTime: 3000, + }); + + return { + resourceSlotsInRG, + deviceMetaData, + mergedResourceSlots: _.merge({}, deviceMetaData, resourceSlotsInRG), + refresh: checkUpdate, + isLoading, + }; +}; + interface UserInfo { full_name: string; email: string;