From d2ba5696bb97ad19dd83d02b363fb52b18150d80 Mon Sep 17 00:00:00 2001 From: ironAiken2 <51399982+ironAiken2@users.noreply.github.com> Date: Thu, 30 Apr 2026 03:07:39 +0000 Subject: [PATCH] feat(FR-2619): migrate single-folder detail reads to Strawberry V2 vfolderV2 query (#6842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #6834 (FR-2619) > [!IMPORTANT] > ## Version branching > > This PR routes consumers **directly to the V2 path** with no V1 fallback. > > - **If shipped in a 26.4.x release** — version branching **NOT required**. > The 26.4.x line pairs with manager ≥ 26.4.2, so `vfolderV2` / `VFolder` is always available. The V1 files kept in tree are only there for the in-tree compatibility window and can be removed in a follow-up cleanup PR. > > - **If shipped in a 26.5.x or later release** — version branching **IS required**. > WebUI 26.5+ may pair with older managers that predate `vfolderV2`. Before merge, consumers (`FolderExplorerOpener`, the inline previews, etc.) must be updated to dynamically select V1 or V2 based on manager capability. ## Summary Per the FR-2572 epic guideline ("do not modify legacy V1 files; create V2 siblings alongside and switch consumers"), this PR migrates the single-folder detail read paths to the Strawberry V2 `vfolderV2(vfolderId)` query. **V1 files are left untouched** — new `*V2.tsx` siblings are added and only the entry-point consumers are switched to V2. ## New V2 siblings Built on the `VFolder` fragment / `vfolderV2(vfolderId)` query: - `react/src/components/FolderExplorerModalV2.tsx` - `react/src/components/FolderExplorerHeaderV2.tsx` - `react/src/components/EditableVFolderNameV2.tsx` - `react/src/components/FileBrowserButtonV2.tsx` - `react/src/components/SFTPServerButtonV2.tsx` - `react/src/components/VFolderNodeDescriptionV2.tsx` - `react/src/components/VirtualFolderNodeItems/VirtualFolderPathV2.tsx` - `react/src/components/VFolderLazyViewV2.tsx` - `react/src/hooks/useVirtualFolderNodePathV2.ts` ## Consumer switches Only three entry points are touched. Everything reachable from them automatically uses V2: - `react/src/components/FolderExplorerOpener.tsx` — the root-mounted opener now lazy-imports `FolderExplorerModalV2`. Every `useFolderExplorerOpener().open(id)` / `generateFolderPath(id)` call site routes through V2 automatically (no per-caller change needed). - `react/src/components/ServiceLauncherPageContent.tsx` — inline preview switched to `VFolderLazyViewV2`. - `react/src/pages/EndpointDetailPage.tsx` — inline preview switched to `VFolderLazyViewV2`. ## TODO marker - `react/src/components/MountedVFolderLinks.tsx` — `ComputeSessionNode` does not yet expose a V2 `VFolder` connection, so `MountedVFolderLinks` / `FolderLink` / `SessionDetailContent` cannot migrate. A `TODO(needs-backend)` comment is placed on the V1 fragment to migrate them once the backend adds the connection. ## Out of scope - `SessionDetailContent.tsx`, `MountedVFolderLinks.tsx`, `FolderLink.tsx` — blocked on the backend dependency above. - `VFolderMountFormItem.tsx` — uses the `vfolder_nodes(...)` list query, not single detail read; covered by FR-2685 (selector migration). - `LegacyModelCardModal.tsx`, `LegacyModelTryContentButton.tsx`, `pages/LegacyModelStoreListPage.tsx` — gated behind legacy model-store flows; deferred per the FR-2619 issue body. ## V2 schema gaps (TODO(needs-backend) markers in code) - `accessControl.permission` is the *mount* permission (`READ_ONLY` / `READ_WRITE` / `RW_DELETE`), not an effective per-user action permission set. The explorer modal's delete/write checks read it as a best-effort approximation. - V2 `VFolder` does not yet expose quota fields (`max_size`, `max_files`), so the MaxSize row is hidden. - The mount-permission update still goes through the legacy REST endpoint (`baiClient.vfolder.update_folder`); V2 has no equivalent mutation yet. ## Verification \`bash scripts/verify.sh\` — Relay / Lint / Format / TypeScript all PASS. --- **Checklist:** (if applicable) - [ ] Documentation - [x] Minimum required manager version — V2 `vfolderV2` / `VFolder` requires manager 26.4.2+ - [ ] Specific setting for review (eg., KB link, endpoint or how to setup) - [ ] Minimum requirements to check during review - [ ] Test case(s) to demonstrate the difference of before/after --- .../components/BAIVFolderNotificationItem.tsx | 4 +- .../src/components/EditableVFolderNameV2.tsx | 281 ++++++++++++++ react/src/components/FileBrowserButtonV2.tsx | 191 +++++++++ .../src/components/FolderExplorerHeaderV2.tsx | 129 +++++++ .../src/components/FolderExplorerModalV2.tsx | 365 ++++++++++++++++++ react/src/components/FolderExplorerOpener.tsx | 2 +- react/src/components/MountedVFolderLinks.tsx | 6 + react/src/components/SFTPServerButtonV2.tsx | 210 ++++++++++ .../components/ServiceLauncherPageContent.tsx | 4 +- react/src/components/VFolderLazyViewV2.tsx | 68 ++++ .../components/VFolderNodeDescriptionV2.tsx | 333 ++++++++++++++++ react/src/components/VFolderNodesV2.tsx | 5 +- .../VirtualFolderPathV2.tsx | 99 +++++ react/src/hooks/useVirtualFolderNodePathV2.ts | 50 +++ react/src/pages/EndpointDetailPage.tsx | 4 +- 15 files changed, 1741 insertions(+), 10 deletions(-) create mode 100644 react/src/components/EditableVFolderNameV2.tsx create mode 100644 react/src/components/FileBrowserButtonV2.tsx create mode 100644 react/src/components/FolderExplorerHeaderV2.tsx create mode 100644 react/src/components/FolderExplorerModalV2.tsx create mode 100644 react/src/components/SFTPServerButtonV2.tsx create mode 100644 react/src/components/VFolderLazyViewV2.tsx create mode 100644 react/src/components/VFolderNodeDescriptionV2.tsx create mode 100644 react/src/components/VirtualFolderNodeItems/VirtualFolderPathV2.tsx create mode 100644 react/src/hooks/useVirtualFolderNodePathV2.ts diff --git a/react/src/components/BAIVFolderNotificationItem.tsx b/react/src/components/BAIVFolderNotificationItem.tsx index 8dd4748642..a6ed29b8cc 100644 --- a/react/src/components/BAIVFolderNotificationItem.tsx +++ b/react/src/components/BAIVFolderNotificationItem.tsx @@ -16,8 +16,8 @@ import dayjs from 'dayjs'; import * as _ from 'lodash-es'; import { useTranslation } from 'react-i18next'; import { graphql, useFragment } from 'react-relay'; -import { useNavigate } from 'react-router-dom'; import { BAIVFolderNotificationItemFragment$key } from 'src/__generated__/BAIVFolderNotificationItemFragment.graphql'; +import { useWebUINavigate } from 'src/hooks'; import { NotificationState, useSetBAINotification, @@ -41,7 +41,7 @@ const BAIVFolderNotificationItem: React.FC = ({ }) => { 'use memo'; - const navigate = useNavigate(); + const navigate = useWebUINavigate(); const { t } = useTranslation(); const { token } = theme.useToken(); const { closeNotification } = useSetBAINotification(); diff --git a/react/src/components/EditableVFolderNameV2.tsx b/react/src/components/EditableVFolderNameV2.tsx new file mode 100644 index 0000000000..54684b7dd0 --- /dev/null +++ b/react/src/components/EditableVFolderNameV2.tsx @@ -0,0 +1,281 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { EditableVFolderNameV2Fragment$key } from '../__generated__/EditableVFolderNameV2Fragment.graphql'; +import { EditableVFolderNameV2RefetchQuery } from '../__generated__/EditableVFolderNameV2RefetchQuery.graphql'; +import { useSuspendedBackendaiClient } from '../hooks'; +import { useCurrentUserInfo } from '../hooks/backendai'; +import { useTanMutation } from '../hooks/reactQueryAlias'; +import { useCurrentProjectValue } from '../hooks/useCurrentProject'; +import { isDeletedCategory } from '../pages/VFolderNodeListPage'; +import { useFolderExplorerOpener } from './FolderExplorerOpener'; +import { + theme, + Form, + Input, + App, + GetProps, + Typography, + InputProps, +} from 'antd'; +import { BAILink, toLocalId, useErrorMessageResolver } from 'backend.ai-ui'; +import * as _ from 'lodash-es'; +import { CornerDownLeftIcon } from 'lucide-react'; +import React, { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + graphql, + fetchQuery, + useFragment, + useRelayEnvironment, +} from 'react-relay'; + +type EditableVFolderNameV2Props = { + vfolderNodeFrgmt: EditableVFolderNameV2Fragment$key; + enableLink?: boolean; + inputProps?: InputProps; + onEditEnd?: () => void; + onEditStart?: () => void; +} & ( + | ({ component?: typeof Typography.Text } & Omit< + GetProps, + 'children' + >) + | ({ component: typeof Typography.Title } & Omit< + GetProps, + 'children' + >) +); + +const EditableVFolderNameV2: React.FC = ({ + component: Component = Typography.Text, + vfolderNodeFrgmt, + editable: editableOfProps, + style, + enableLink = true, + onEditEnd, + onEditStart, + inputProps, + ...otherProps +}) => { + 'use memo'; + const vfolderNode = useFragment( + graphql` + fragment EditableVFolderNameV2Fragment on VFolder { + id + status + metadata { + name + } + ownership { + userId + projectId + } + } + `, + vfolderNodeFrgmt, + ); + const [optimisticName, setOptimisticName] = useState( + vfolderNode.metadata?.name, + ); + const [userInfo] = useCurrentUserInfo(); + const currentProject = useCurrentProjectValue(); + const baiClient = useSuspendedBackendaiClient(); + const renameMutation = useTanMutation({ + mutationFn: (input: { id: string; name: string }) => { + return baiClient.vfolder.rename(input.name, toLocalId(vfolderNode?.id)); + }, + }); + const relayEnv = useRelayEnvironment(); + + const { t } = useTranslation(); + const { token } = theme.useToken(); + const { message } = App.useApp(); + const { getErrorMessage } = useErrorMessageResolver(); + const { generateFolderPath } = useFolderExplorerOpener(); + const [isEditing, setIsEditing] = useState(false); + + const isEditingAllowed = + editableOfProps && + (userInfo.uuid === vfolderNode.ownership?.userId || + currentProject?.id === vfolderNode.ownership?.projectId) && + !isDeletedCategory(vfolderNode.status); + + const isPendingRenameMutation = + renameMutation.isPending || optimisticName !== vfolderNode.metadata?.name; + + // focus back to the text component after editing for better UX related to keyboard shortcuts + const textRef = useRef(null); + const focusFallback = () => { + setTimeout(() => { + textRef.current?.focus(); + }, 0); + }; + + return ( + <> + {(!isEditing || isPendingRenameMutation) && ( + { + setIsEditing(true); + onEditStart?.(); + }, + onEnd: () => { + setIsEditing(false); + onEditEnd?.(); + }, + onCancel: () => { + setIsEditing(false); + onEditEnd?.(); + }, + triggerType: ['icon'], + ...(!_.isBoolean(editableOfProps) ? editableOfProps : {}), + } + : false + } + style={{ + // after editing, focus this element, remove outline + outline: 'none', + ...style, + color: isPendingRenameMutation + ? token.colorTextTertiary + : style?.color, + }} + title={vfolderNode.metadata?.name || undefined} + {...otherProps} + > + {enableLink && !isEditing && ( + + {isPendingRenameMutation + ? optimisticName + : vfolderNode.metadata?.name} + + )} + {!enableLink && + (isPendingRenameMutation + ? optimisticName + : vfolderNode.metadata?.name)} + + )} + {isEditing && !isPendingRenameMutation && ( +
{ + setIsEditing(false); + focusFallback(); + if (values.vfolderName === vfolderNode.metadata?.name) { + onEditEnd?.(); + return; + } + setOptimisticName(values.vfolderName); + renameMutation.mutate( + { + id: vfolderNode.id, + name: values.vfolderName, + }, + { + onSuccess: () => { + onEditEnd?.(); + message.success(t('data.folders.FileRenamed')); + return fetchQuery( + relayEnv, + graphql` + query EditableVFolderNameV2RefetchQuery( + $vfolderId: UUID! + ) { + vfolderV2(vfolderId: $vfolderId) { + id + metadata { + name + } + } + } + `, + { + vfolderId: toLocalId(vfolderNode.id), + }, + ).toPromise(); + }, + onError: (error) => { + onEditEnd?.(); + const errorMessage = getErrorMessage(error); + if ( + errorMessage.includes( + 'One of your accessible vfolders already has the name you requested.', + ) + ) { + message.error(t('data.FolderAlreadyExists')); + } else { + message.error(errorMessage); + } + setOptimisticName(vfolderNode.metadata?.name); + }, + }, + ); + }} + initialValues={{ + vfolderName: vfolderNode.metadata?.name, + }} + style={{ + flex: 1, + }} + > + + + } + autoFocus + onKeyDown={(e) => { + // when press escape key, cancel editing + if (e.key === 'Escape') { + e.stopPropagation(); + setIsEditing(false); + focusFallback(); + onEditEnd?.(); + } + }} + {...inputProps} + /> + + + )} + + ); +}; + +export default EditableVFolderNameV2; diff --git a/react/src/components/FileBrowserButtonV2.tsx b/react/src/components/FileBrowserButtonV2.tsx new file mode 100644 index 0000000000..d644794870 --- /dev/null +++ b/react/src/components/FileBrowserButtonV2.tsx @@ -0,0 +1,191 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { useWebUINavigate } from '../hooks'; +import { PrimaryAppOption } from './ComputeSessionNodeItems/SessionActionButtons'; +import { EllipsisOutlined } from '@ant-design/icons'; +import { App, Dropdown, Image, Space, Tooltip } from 'antd'; +import { + BAIButton, + BAIButtonProps, + toLocalId, + useBAILogger, + useErrorMessageResolver, +} from 'backend.ai-ui'; +import * as _ from 'lodash-es'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useFragment } from 'react-relay'; +import { FileBrowserButtonV2Fragment$key } from 'src/__generated__/FileBrowserButtonV2Fragment.graphql'; +import { useCurrentDomainValue, useSuspendedBackendaiClient } from 'src/hooks'; +import { useCurrentProjectValue } from 'src/hooks/useCurrentProject'; +import { useDefaultFileBrowserImageWithFallback } from 'src/hooks/useDefaultImagesWithFallback'; +import { useMergedAllowedStorageHostPermission } from 'src/hooks/useMergedAllowedStorageHostPermission'; +import { + StartSessionWithDefaultValue, + useStartSession, +} from 'src/hooks/useStartSession'; + +interface FileBrowserButtonV2Props extends BAIButtonProps { + showTitle?: boolean; + vfolderNodeFrgmt: FileBrowserButtonV2Fragment$key; +} +const FileBrowserButtonV2: React.FC = ({ + showTitle = true, + vfolderNodeFrgmt, + ...buttonProps +}) => { + 'use memo'; + const { t } = useTranslation(); + const { message, modal } = App.useApp(); + const { logger } = useBAILogger(); + + const webuiNavigate = useWebUINavigate(); + + const baiClient = useSuspendedBackendaiClient(); + const currentDomain = useCurrentDomainValue(); + const currentProject = useCurrentProjectValue(); + if (!currentProject.id) { + throw new Error('Project ID is required for FileBrowserButtonV2'); + } + const currentUserAccessKey = baiClient?._config?.accessKey; + const { unitedAllowedPermissionByVolume } = + useMergedAllowedStorageHostPermission( + currentDomain, + currentProject.id, + currentUserAccessKey, + ); + + const { getErrorMessage } = useErrorMessageResolver(); + const { startSessionWithDefault, upsertSessionNotification } = + useStartSession(); + + const filebrowserImage = useDefaultFileBrowserImageWithFallback(); + + const vfolderNode = useFragment( + graphql` + fragment FileBrowserButtonV2Fragment on VFolder { + id + host + } + `, + vfolderNodeFrgmt, + ); + + const hasAccessPermission = _.includes( + unitedAllowedPermissionByVolume[vfolderNode?.host ?? ''], + 'mount-in-session', + ); + + const getTooltipTitle = () => { + if (!hasAccessPermission) { + return t('data.explorer.NoPermissionToMountFolder'); + } else if (filebrowserImage === null) { + return t('data.explorer.NoImagesSupportingFileBrowser'); + } else if (!showTitle && filebrowserImage) { + return t('data.explorer.ExecuteFileBrowser'); + } else return ''; + }; + + // Helper to create launcher value for filebrowser + const createFilebrowserLauncherValue = (): StartSessionWithDefaultValue => ({ + sessionName: `filebrowser-${toLocalId(vfolderNode.id || '')}`, + sessionType: 'interactive', + environments: { + version: filebrowserImage || '', + }, + allocationPreset: 'minimum-required', + cluster_mode: 'single-node', + cluster_size: 1, + mount_ids: [toLocalId(vfolderNode.id || '').replaceAll('-', '')], + reuseIfExists: true, + }); + + return ( + + + + } + disabled={!filebrowserImage || !hasAccessPermission} + action={async () => { + if (!filebrowserImage) { + return; + } + const fileBrowserFormValue = createFilebrowserLauncherValue(); + await startSessionWithDefault(fileBrowserFormValue) + .then((results) => { + if (results?.fulfilled && results.fulfilled.length > 0) { + upsertSessionNotification(results.fulfilled, [ + { + key: `filebrowser-${toLocalId(vfolderNode.id || '')}`, + extraData: { + appName: 'filebrowser', + } as PrimaryAppOption, + }, + ]); + } + if (results?.rejected && results.rejected.length > 0) { + const error = results.rejected[0].reason; + modal.error({ + title: error?.title, + content: getErrorMessage(error), + }); + } + }) + .catch((error) => { + logger.error( + 'Unexpected error during session creation:', + error, + ); + message.error(t('error.UnexpectedError')); + }); + }} + {...buttonProps} + > + {showTitle && t('data.explorer.ExecuteFileBrowser')} + + { + const launcherValue = createFilebrowserLauncherValue(); + const params = new URLSearchParams(); + params.set('formValues', JSON.stringify(launcherValue)); + params.set('step', '4'); + webuiNavigate({ + pathname: '/session/start', + search: params.toString(), + }); + }, + }, + ], + }} + > + } /> + + + + ); +}; + +export default FileBrowserButtonV2; diff --git a/react/src/components/FolderExplorerHeaderV2.tsx b/react/src/components/FolderExplorerHeaderV2.tsx new file mode 100644 index 0000000000..ff6a19e3b3 --- /dev/null +++ b/react/src/components/FolderExplorerHeaderV2.tsx @@ -0,0 +1,129 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { FolderExplorerHeaderV2Fragment$key } from '../__generated__/FolderExplorerHeaderV2Fragment.graphql'; +import EditableVFolderNameV2 from './EditableVFolderNameV2'; +import ErrorBoundaryWithNullFallback from './ErrorBoundaryWithNullFallback'; +import FileBrowserButtonV2 from './FileBrowserButtonV2'; +import SFTPServerButtonV2 from './SFTPServerButtonV2'; +import VFolderNodeIdenticonV2 from './VFolderNodeIdenticonV2'; +import { theme, Typography, Skeleton, Grid } from 'antd'; +import { BAIFlex } from 'backend.ai-ui'; +import React, { Suspense } from 'react'; +import { graphql, useFragment } from 'react-relay'; + +interface FolderExplorerHeaderV2Props { + vfolderNodeFrgmt?: FolderExplorerHeaderV2Fragment$key | null; + titleStyle?: React.CSSProperties; +} + +const FolderExplorerHeaderV2: React.FC = ({ + vfolderNodeFrgmt, + titleStyle, +}) => { + 'use memo'; + + const { token } = theme.useToken(); + const { lg } = Grid.useBreakpoint(); + + const vfolderNode = useFragment( + graphql` + fragment FolderExplorerHeaderV2Fragment on VFolder { + id @required(action: THROW) + unmanagedPath + ...VFolderNodeIdenticonV2Fragment + ...EditableVFolderNameV2Fragment + ...FileBrowserButtonV2Fragment + ...SFTPServerButtonV2Fragment + } + `, + vfolderNodeFrgmt ?? null, + ); + + return ( + + + {vfolderNode ? ( + + ) : ( + + )} + {vfolderNode && ( + + )} + + + {vfolderNode && !vfolderNode?.unmanagedPath ? ( + }> + + + + + + + + ) : null} + + + ); +}; + +export default FolderExplorerHeaderV2; diff --git a/react/src/components/FolderExplorerModalV2.tsx b/react/src/components/FolderExplorerModalV2.tsx new file mode 100644 index 0000000000..05f82d16de --- /dev/null +++ b/react/src/components/FolderExplorerModalV2.tsx @@ -0,0 +1,365 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { useFileUploadManager } from './FileUploadManager'; +import FolderExplorerHeaderV2 from './FolderExplorerHeaderV2'; +import { useFolderExplorerOpener } from './FolderExplorerOpener'; +import VFolderNodeDescriptionV2 from './VFolderNodeDescriptionV2'; +import VFolderTextFileEditorModal from './VFolderTextFileEditorModal'; +import { Alert, Divider, Grid, Skeleton, Splitter, theme } from 'antd'; +import { createStyles } from 'antd-style'; +import { RcFile } from 'antd/es/upload'; +import { + BAIFileExplorer, + BAIFileExplorerRef, + BAIFlex, + BAILink, + BAIModal, + BAIModalProps, + BAIUnmountAfterClose, + useFetchKey, + useInterval, + VFolderFile, +} from 'backend.ai-ui'; +import * as _ from 'lodash-es'; +import { Suspense, useDeferredValue, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useLazyLoadQuery } from 'react-relay'; +import { FolderExplorerModalV2Query } from 'src/__generated__/FolderExplorerModalV2Query.graphql'; +import { formatToUUID } from 'src/helper'; +import { useCurrentDomainValue, useSuspendedBackendaiClient } from 'src/hooks'; +import { useSetBAINotification } from 'src/hooks/useBAINotification'; +import { useCurrentProjectValue } from 'src/hooks/useCurrentProject'; +import { useMergedAllowedStorageHostPermission } from 'src/hooks/useMergedAllowedStorageHostPermission'; + +const useStyles = createStyles(({ css }) => ({ + baiModalHeader: css` + .ant-modal-title { + width: 100%; + } + `, +})); + +export interface FolderExplorerElement extends HTMLDivElement { + _fetchVFolder: () => void; + _openDeleteMultipleFileDialog: () => void; + openMkdirDialog: () => void; + handleUpload: (name: 'file' | 'folder') => void; +} + +export interface FileItem { + name: string; + type: string; + size: number; + mode: string; + created: string; + modified: string; +} + +interface FolderExplorerProps extends BAIModalProps { + vfolderID: string; + onRequestClose: () => void; +} + +const FolderExplorerModalV2: React.FC = ({ + vfolderID, + onRequestClose, + ...modalProps +}) => { + 'use memo'; + + const { t } = useTranslation(); + const { token } = theme.useToken(); + const { xl } = Grid.useBreakpoint(); + const { styles } = useStyles(); + + const [fetchKey, updateFetchKey] = useFetchKey(); + const baiClient = useSuspendedBackendaiClient(); + const currentDomain = useCurrentDomainValue(); + const currentProject = useCurrentProjectValue(); + if (!currentProject.id) { + throw new Error('Project ID is required for FolderExplorerModalV2'); + } + const currentUserAccessKey = baiClient?._config?.accessKey; + const fileExplorerRef = useRef(null); + const { unitedAllowedPermissionByVolume } = + useMergedAllowedStorageHostPermission( + currentDomain, + currentProject.id, + currentUserAccessKey, + ); + const bodyRef = useRef(null); + + const deferredOpen = useDeferredValue(modalProps.open); + // `vfolderID` comes from the URL query param `?folder=…` via + // `FolderExplorerOpener`, where dashes are stripped for a cleaner URL. + // The V2 `vfolderV2` resolver expects a canonical `UUID!`, so restore the + // dashed form via the shared `formatToUUID` helper. + const vfolderUuid = + vfolderID.length === 32 ? formatToUUID(vfolderID) : vfolderID; + const { vfolderNode } = useLazyLoadQuery( + graphql` + query FolderExplorerModalV2Query($vfolderId: UUID!) { + vfolderNode: vfolderV2(vfolderId: $vfolderId) { + unmanagedPath + host + id + metadata { + name + } + ownership { + projectId + project { + basicInfo { + name + } + } + } + ...FolderExplorerHeaderV2Fragment + ...VFolderNodeDescriptionV2Fragment + } + } + `, + { vfolderId: vfolderUuid }, + { + // Only fetch when both deferredOpen and modalProps.open are true to prevent unnecessary requests during React transitions + fetchPolicy: + deferredOpen && modalProps.open ? 'store-and-network' : 'store-only', + }, + ); + + // FIXME: This is a temporary workaround to notify file deletion to use WebUI Notification. + const { upsertNotification, closeNotification } = useSetBAINotification(); + const { generateFolderPath } = useFolderExplorerOpener(); + const [deletingFilePaths, setDeletingFilePaths] = useState>([]); + const [editingFile, setEditingFile] = useState<{ + file: VFolderFile; + currentPath: string; + } | null>(null); + const { uploadStatus, uploadFiles } = useFileUploadManager( + vfolderNode?.id, + vfolderNode?.metadata?.name || undefined, + ); + // Polling to update fetchKey when there are pending uploads + useInterval( + () => { + fileExplorerRef.current?.refetch(); + }, + uploadStatus && !_.isEmpty(uploadStatus?.pendingFiles) ? 5000 : null, + ); + // Also update fetchKey when uploadStatus changes to completed + useEffect(() => { + if (uploadStatus && _.isEmpty(uploadStatus?.pendingFiles)) { + updateFetchKey(); + } + }, [uploadStatus, updateFetchKey]); + + const hasDownloadContentPermission = _.includes( + unitedAllowedPermissionByVolume[vfolderNode?.host ?? ''], + 'download-file', + ); + // TODO(needs-backend): write/delete capability should be derived from the + // caller's *effective* permission set on this entity (e.g., + // `delete_content`, `write_content`), not from the folder's mount + // permission. The V2 schema currently exposes only the mount permission via + // `accessControl.permission` (`READ_ONLY` / `READ_WRITE` / `RW_DELETE`), + // which is what the folder is mounted *as* into a session — not what the + // caller is allowed to do on the folder itself. Until the backend exposes a + // proper effective permission set, allow all callers and let the server + // enforce authorization. See FR-2619 follow-up. + const hasDeleteContentPermission = true; + const hasWriteContentPermission = true; + // TODO: Skip permission check due to inaccurate API response. Update when API is fixed. + const hasNoPermissions = false; + + const fileExplorerElement = vfolderNode?.unmanagedPath ? ( + + ) : !hasNoPermissions && vfolderNode ? ( + { + uploadFiles(files, vfolderID, currentPath); + }} + onDeleteFilesInBackground={( + bgTaskId, + targetVFolderId, + deletingFilePaths, + ) => { + setDeletingFilePaths(deletingFilePaths); + upsertNotification({ + key: `delete:${bgTaskId}`, + open: true, + message: ( + + {t('explorer.VFolder')}:  + { + closeNotification(`delete:${bgTaskId}`); + }} + >{`${vfolderNode.metadata?.name}`} + + ), + backgroundTask: { + status: 'pending', + taskId: bgTaskId, + promise: null, + percent: 0, + onChange: { + pending: t('explorer.DeletingSelectedItems'), + resolved: () => { + setDeletingFilePaths([]); + return t('explorer.SelectedItemsDeletedSuccessfully'); + }, + rejected: () => { + setDeletingFilePaths([]); + return t('explorer.SelectedItemsDeletionFailed'); + }, + }, + }, + }); + }} + enableDownload={hasDownloadContentPermission} + enableDelete={hasDeleteContentPermission} + enableWrite={hasWriteContentPermission} + enableEdit={hasWriteContentPermission} + tableProps={{ + scroll: xl + ? { x: 'max-content' } + : { x: 'max-content', y: 'calc(100vh - 400px)' }, + }} + style={{ + paddingBottom: xl ? token.paddingLG : 0, + }} + fileDropContainerRef={bodyRef} + onClickEditFile={(file, currentPath) => { + setEditingFile({ file, currentPath }); + }} + /> + ) : null; + + const vFolderDescriptionElement = vfolderNode ? ( + + ) : null; + + return ( + + ) : null + } + bodyProps={{ + ref: bodyRef, + }} + onCancel={() => { + onRequestClose(); + }} + {...modalProps} + > + }> + {/* Use instead of using `loading` prop because layout align issue. */} + {deferredOpen !== modalProps.open || vfolderNode === undefined ? ( + + ) : ( + + {vfolderNode === null ? ( + + ) : hasNoPermissions ? ( + + ) : currentProject?.id !== vfolderNode?.ownership?.projectId && + !!vfolderNode?.ownership?.projectId ? ( + + ) : null} + + {xl ? ( + + + {fileExplorerElement} + + + {vFolderDescriptionElement} + + + ) : ( + + {fileExplorerElement} + + {vFolderDescriptionElement} + + )} + + )} + + + { + if (success) { + fileExplorerRef.current?.refetch(); + } + setEditingFile(null); + }} + /> + + + ); +}; + +export default FolderExplorerModalV2; diff --git a/react/src/components/FolderExplorerOpener.tsx b/react/src/components/FolderExplorerOpener.tsx index 66598ff204..b976c8992a 100644 --- a/react/src/components/FolderExplorerOpener.tsx +++ b/react/src/components/FolderExplorerOpener.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { useLocation } from 'react-router-dom'; import { StringParam, useQueryParam } from 'use-query-params'; -const FolderExplorerModal = React.lazy(() => import('./FolderExplorerModal')); +const FolderExplorerModal = React.lazy(() => import('./FolderExplorerModalV2')); const FolderExplorerOpener = () => { const [folderId, setFolderId] = useQueryParam('folder', StringParam); diff --git a/react/src/components/MountedVFolderLinks.tsx b/react/src/components/MountedVFolderLinks.tsx index 33315d30d8..d1a9676ef2 100644 --- a/react/src/components/MountedVFolderLinks.tsx +++ b/react/src/components/MountedVFolderLinks.tsx @@ -21,6 +21,12 @@ const MountedVFolderLinks: React.FC = ({ }) => { const baiClient = useSuspendedBackendaiClient(); + // TODO(needs-backend): the FR-2619 V2 migration cannot reach this surface + // because `ComputeSessionNode` only exposes the V1 `vfolder_nodes` + // (`VirtualFolderConnection`) field — there is no V2 `VFolder` connection + // on the session type. Migrate `MountedVFolderLinks`, `FolderLink`, and the + // session query in `SessionDetailContent` to a V2 fragment once the backend + // adds a `VFolder` (Strawberry V2) connection on `ComputeSessionNode`. const session = useFragment( graphql` fragment MountedVFolderLinksFragment on ComputeSessionNode { diff --git a/react/src/components/SFTPServerButtonV2.tsx b/react/src/components/SFTPServerButtonV2.tsx new file mode 100644 index 0000000000..7dc15494e8 --- /dev/null +++ b/react/src/components/SFTPServerButtonV2.tsx @@ -0,0 +1,210 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { useWebUINavigate } from '../hooks'; +import { EllipsisOutlined } from '@ant-design/icons'; +import { App, Dropdown, Image, Space, Tooltip } from 'antd'; +import { + BAIButton, + BAIButtonProps, + toLocalId, + useBAILogger, + useErrorMessageResolver, +} from 'backend.ai-ui'; +import * as _ from 'lodash-es'; +import { useTranslation } from 'react-i18next'; +import { graphql, useFragment } from 'react-relay'; +import { SFTPServerButtonV2Fragment$key } from 'src/__generated__/SFTPServerButtonV2Fragment.graphql'; +import { useCurrentDomainValue, useSuspendedBackendaiClient } from 'src/hooks'; +import { + useCurrentProjectValue, + useResourceGroupsForCurrentProject, +} from 'src/hooks/useCurrentProject'; +import { useDefaultSystemSSHImageWithFallback } from 'src/hooks/useDefaultImagesWithFallback'; +import { useMergedAllowedStorageHostPermission } from 'src/hooks/useMergedAllowedStorageHostPermission'; +import { + StartSessionWithDefaultValue, + useStartSession, +} from 'src/hooks/useStartSession'; + +interface SFTPServerButtonV2Props extends BAIButtonProps { + showTitle?: boolean; + vfolderNodeFrgmt: SFTPServerButtonV2Fragment$key; +} + +const SFTPServerButtonV2: React.FC = ({ + showTitle = true, + vfolderNodeFrgmt, + ...buttonProps +}) => { + 'use memo'; + + const { logger } = useBAILogger(); + const { t } = useTranslation(); + const { message, modal } = App.useApp(); + + const webuiNavigate = useWebUINavigate(); + + const baiClient = useSuspendedBackendaiClient(); + const currentDomain = useCurrentDomainValue(); + const currentProject = useCurrentProjectValue(); + if (!currentProject.id) { + throw new Error('Project ID is required for SFTPServerButtonV2'); + } + const currentUserAccessKey = baiClient?._config?.accessKey; + const { unitedAllowedPermissionByVolume } = + useMergedAllowedStorageHostPermission( + currentDomain, + currentProject.id, + currentUserAccessKey, + ); + const { vhostInfo: vhostInfoByCurrentProject } = + useResourceGroupsForCurrentProject(); + + const { getErrorMessage } = useErrorMessageResolver(); + const { startSessionWithDefault, upsertSessionNotification } = + useStartSession(); + + const { systemSSHImage } = useDefaultSystemSSHImageWithFallback(); + + const vfolderNode = useFragment( + graphql` + fragment SFTPServerButtonV2Fragment on VFolder { + id + host + } + `, + vfolderNodeFrgmt, + ); + + // Verify that the current user has access to the volume of the vfolder. + // Check the project has SFTP scaling groups for the host of the vfolder. + const sftpScalingGroupByCurrentProject = + vhostInfoByCurrentProject?.volume_info[vfolderNode?.host || ''] + ?.sftp_scaling_groups; + // Verify that the current project has access to the volumes in the folder. + // Check the user has 'mount-in-session' permission united by domain, project, and keypair resource policy. + const hasAccessPermission = _.includes( + unitedAllowedPermissionByVolume[vfolderNode?.host ?? ''], + 'mount-in-session', + ); + + const getTooltipTitle = () => { + if (!hasAccessPermission) { + return t('data.explorer.NoPermissionToMountFolder'); + } else if (_.isEmpty(sftpScalingGroupByCurrentProject)) { + return t('data.explorer.NoSFTPSupportingScalingGroup'); + } else if (!systemSSHImage) { + return t('data.explorer.NoImagesSupportingSystemSession'); + } else if (!showTitle && systemSSHImage) { + return t('data.explorer.RunSSH/SFTPserver'); + } else return ''; + }; + + // Helper to create launcher value for SFTP session + const createSftpLauncherValue = (): StartSessionWithDefaultValue => ({ + sessionName: `sftp-${toLocalId(vfolderNode?.id || '')}`, + sessionType: 'system', + ...(baiClient._config?.systemSSHImage && + baiClient._config?.allow_manual_image_name_for_session + ? { + environments: { + manual: baiClient._config.systemSSHImage, + }, + } + : { + environments: { + version: systemSSHImage || '', + }, + }), + cluster_mode: 'single-node', + cluster_size: 1, + mount_ids: [toLocalId(vfolderNode?.id || '').replaceAll('-', '')], + resourceGroup: sftpScalingGroupByCurrentProject?.[0], + reuseIfExists: true, + }); + + return ( + + + + } + action={async () => { + const sftpSessionConf = createSftpLauncherValue(); + await startSessionWithDefault(sftpSessionConf) + .then((results) => { + if (results?.fulfilled && results.fulfilled.length > 0) { + // set notification key for handling duplicate session creation + upsertSessionNotification(results.fulfilled, [ + { + key: `sftp-${toLocalId(vfolderNode?.id || '')}`, + }, + ]); + } + if (results?.rejected && results.rejected.length > 0) { + const error = results.rejected[0].reason; + modal.error({ + title: error?.title, + content: getErrorMessage(error), + }); + } + }) + .catch((error) => { + logger.error( + 'Unexpected error during session creation:', + error, + ); + message.error(t('error.UnexpectedError')); + }); + }} + {...buttonProps} + > + {showTitle && t('data.explorer.RunSSH/SFTPserver')} + + { + const launcherValue = createSftpLauncherValue(); + const params = new URLSearchParams(); + params.set('formValues', JSON.stringify(launcherValue)); + params.set('step', '4'); + webuiNavigate({ + pathname: '/session/start', + search: params.toString(), + }); + }, + }, + ], + }} + > + } /> + + + + ); +}; + +export default SFTPServerButtonV2; diff --git a/react/src/components/ServiceLauncherPageContent.tsx b/react/src/components/ServiceLauncherPageContent.tsx index f892bb6bbe..e8b83bfdb8 100644 --- a/react/src/components/ServiceLauncherPageContent.tsx +++ b/react/src/components/ServiceLauncherPageContent.tsx @@ -60,7 +60,7 @@ import ResourceAllocationFormItems, { ResourceAllocationFormValue, } from './SessionFormItems/ResourceAllocationFormItems'; import SwitchToProjectButton from './SwitchToProjectButton'; -import VFolderLazyView from './VFolderLazyView'; +import VFolderLazyViewV2 from './VFolderLazyViewV2'; import VFolderSelect from './VFolderSelect'; import VFolderTableFormItem from './VFolderTableFormItem'; import { useDebounceFn } from 'ahooks'; @@ -1626,7 +1626,7 @@ const ServiceLauncherPageContent: React.FC = ({ > }> - diff --git a/react/src/components/VFolderLazyViewV2.tsx b/react/src/components/VFolderLazyViewV2.tsx new file mode 100644 index 0000000000..9da2eb1532 --- /dev/null +++ b/react/src/components/VFolderLazyViewV2.tsx @@ -0,0 +1,68 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { useWebUINavigate } from '../hooks'; +import VFolderNodeIdenticonV2 from './VFolderNodeIdenticonV2'; +import { Typography } from 'antd'; +import { BAIFlex, toLocalId } from 'backend.ai-ui'; +import React from 'react'; +import { graphql, useLazyLoadQuery } from 'react-relay'; +import { useLocation } from 'react-router-dom'; +import { VFolderLazyViewV2Query } from 'src/__generated__/VFolderLazyViewV2Query.graphql'; + +interface VFolderLazyViewV2Props { + uuid: string; + clickable?: boolean; +} +const VFolderLazyViewV2: React.FC = ({ + uuid, + clickable, +}) => { + const location = useLocation(); + + const webuiNavigate = useWebUINavigate(); + + const { vfolderNode } = useLazyLoadQuery( + graphql` + query VFolderLazyViewV2Query($vfolderId: UUID!) { + vfolderNode: vfolderV2(vfolderId: $vfolderId) { + id @required(action: THROW) + metadata { + name + } + ...VFolderNodeIdenticonV2Fragment + } + } + `, + { vfolderId: uuid }, + ); + + return ( + <> + {vfolderNode && ( + + + {clickable ? ( + { + webuiNavigate({ + pathname: location.pathname, + search: new URLSearchParams({ + folder: toLocalId(vfolderNode.id), + }).toString(), + }); + }} + > + {vfolderNode.metadata?.name} + + ) : ( + {vfolderNode.metadata?.name} + )} + + )} + + ); +}; + +export default VFolderLazyViewV2; diff --git a/react/src/components/VFolderNodeDescriptionV2.tsx b/react/src/components/VFolderNodeDescriptionV2.tsx new file mode 100644 index 0000000000..6456bfdafb --- /dev/null +++ b/react/src/components/VFolderNodeDescriptionV2.tsx @@ -0,0 +1,333 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { VFolderNodeDescriptionV2Fragment$key } from '../__generated__/VFolderNodeDescriptionV2Fragment.graphql'; +import { VFolderNodeDescriptionV2PermissionRefreshQuery } from '../__generated__/VFolderNodeDescriptionV2PermissionRefreshQuery.graphql'; +import { useSuspendedBackendaiClient } from '../hooks'; +import { useCurrentUserInfo } from '../hooks/backendai'; +import { useTanMutation } from '../hooks/reactQueryAlias'; +import { useCurrentProjectValue } from '../hooks/useCurrentProject'; +import { useEffectiveAdminRole } from '../hooks/useCurrentUserProjectRoles'; +import { useVirtualFolderPathV2 } from '../hooks/useVirtualFolderNodePathV2'; +import { statusTagColor } from './VFolderNodesV2'; +import VirtualFolderPathV2 from './VirtualFolderNodeItems/VirtualFolderPathV2'; +import { + CheckCircleOutlined, + CloseCircleOutlined, + UserOutlined, +} from '@ant-design/icons'; +import { + App, + Descriptions, + theme, + Typography, + type DescriptionsProps, +} from 'antd'; +import { + filterOutEmpty, + BAIUserUnionIcon, + toLocalId, + BAIFlex, + useErrorMessageResolver, + BAISelect, + BAITag, +} from 'backend.ai-ui'; +import dayjs from 'dayjs'; +import * as _ from 'lodash-es'; +import { useTranslation } from 'react-i18next'; +import { + graphql, + fetchQuery, + useFragment, + useRelayEnvironment, +} from 'react-relay'; + +interface VFolderNodeDescriptionV2Props extends DescriptionsProps { + vfolderNodeFrgmt: VFolderNodeDescriptionV2Fragment$key; +} + +const VFolderNodeDescriptionV2: React.FC = ({ + vfolderNodeFrgmt, + ...props +}) => { + const { t } = useTranslation(); + const { token } = theme.useToken(); + const { message } = App.useApp(); + const { getErrorMessage } = useErrorMessageResolver(); + + const relayEnv = useRelayEnvironment(); + const currentProject = useCurrentProjectValue(); + const baiClient = useSuspendedBackendaiClient(); + const [currentUser] = useCurrentUserInfo(); + const effectiveAdminRole = useEffectiveAdminRole(); + + // TODO(needs-backend): the mount-permission update still goes through the + // legacy REST endpoint (`baiClient.vfolder.update_folder`) because the V2 + // GraphQL schema does not yet expose a corresponding mutation. Replace this + // with a V2 mutation once the backend provides one — see FR-2619 follow-up. + const updateMutation = useTanMutation({ + mutationFn: ({ permission, id }: { permission: string; id: string }) => { + return baiClient.vfolder.update_folder({ permission }, id); + }, + }); + + const vfolderNode = useFragment( + graphql` + fragment VFolderNodeDescriptionV2Fragment on VFolder { + id + host + status + unmanagedPath + metadata { + name + usageMode + cloneable + createdAt + } + accessControl { + permission + ownershipType + } + ownership { + userId + projectId + creatorId + user { + basicInfo { + email + } + } + project { + basicInfo { + name + } + } + } + ...VFolderPermissionCellV2Fragment + ...useVirtualFolderNodePathV2Fragment + } + `, + vfolderNodeFrgmt, + ); + + const { vfolderPath } = useVirtualFolderPathV2(vfolderNode); + + const vfolderId = toLocalId(vfolderNode.id); + + // V2 `VFolderMountPermission` enum → legacy REST permission string mapping + // for the `` below. READ_ONLY → 'ro', READ_WRITE/RW_DELETE → 'rw'. + // NOTE: `accessControl.permission` is the *mount* permission (how this folder + // would be mounted into a session), not the caller's operational rights on + // the folder. When the value is null/undefined we fall back to 'ro' so that + // users without an explicit permission do not see a misleading read-write + // default. See FR-2619 follow-up for a proper permission set. + const currentSelectPermission = + vfolderNode.accessControl?.permission === 'READ_WRITE' || + vfolderNode.accessControl?.permission === 'RW_DELETE' + ? 'rw' + : 'ro'; + + const items: DescriptionsProps['items'] = filterOutEmpty([ + !vfolderNode?.unmanagedPath && { + key: 'path', + label: ( + + {t('data.folders.Path')} + + ), + children: , + }, + { + key: 'status', + label: t('data.folders.Status'), + children: ( + + {_.toUpper(vfolderNode.status ?? '')} + + ), + }, + { + key: 'host', + label: t('data.Host'), + children: vfolderNode.host, + }, + { + key: 'ownership_type', + label: t('data.folders.Ownership'), + children: + vfolderNode?.accessControl?.ownershipType === 'USER' ? ( + + {t('data.User')} + + + ) : ( + + {t('data.Project')} + + + ), + }, + // Mount permission editing is allowed for the folder owner, super admins + // (any project), or the current project's admin when the folder belongs to + // that project. Domain admins are intentionally excluded — they do not + // have implicit per-project ownership rights. + (vfolderNode?.ownership?.userId === currentUser.uuid || + effectiveAdminRole === 'superadmin' || + (effectiveAdminRole === 'currentProjectAdmin' && + vfolderNode?.ownership?.projectId === currentProject?.id)) && { + key: 'permission', + label: t('data.folders.MountPermission'), + children: ( + { + updateMutation.mutate( + { permission: value, id: vfolderId }, + { + onSuccess: () => { + message.success(t('data.permission.PermissionModified')); + document.dispatchEvent( + new CustomEvent('backend-ai-folder-updated'), + ); + + // Refresh the V2 VFolder record so the UI reflects the new + // `accessControl.permission` value. The refetch is fire-and- + // forget; swallow failures so a background refresh error + // does not surface as an unhandled promise rejection. + void fetchQuery( + relayEnv, + graphql` + query VFolderNodeDescriptionV2PermissionRefreshQuery( + $vfolderId: UUID! + ) { + vfolderV2(vfolderId: $vfolderId) { + id + accessControl { + permission + } + } + } + `, + { + vfolderId, + }, + ) + .toPromise() + .catch(() => {}); + }, + onError: (error) => { + message.error(getErrorMessage(error)); + }, + }, + ); + }} + popupMatchSelectWidth={false} + /> + ), + }, + { + key: 'owner', + label: t('data.folders.Owner'), + children: ( + + {vfolderNode?.ownership?.creatorId === currentUser?.uuid ? ( + + ) : ( + + )} + + ), + }, + vfolderNode.ownership?.user?.basicInfo?.email && { + key: 'user_email', + label: t('data.User'), + children: ( + + {vfolderNode.ownership?.user?.basicInfo?.email} + + ), + }, + vfolderNode.ownership?.project?.basicInfo?.name && { + key: 'group_name', + label: t('data.Project'), + children: vfolderNode.ownership?.project?.basicInfo?.name, + }, + { + key: 'cloneable', + label: t('data.folders.Cloneable'), + children: ( + + {vfolderNode.metadata?.cloneable ? ( + + ) : ( + + )} + + ), + }, + // TODO(needs-backend): V2 `VFolder` does not yet expose quota limits + // (`max_size`, `max_files`). Hide the MaxSize row until the backend + // catches up — see FR-2573 follow-up. + { + key: 'usage', + label: t('data.UsageMode'), + children: (() => { + switch (vfolderNode.metadata?.usageMode) { + case 'GENERAL': + return t('data.General'); + case 'DATA': + return t('webui.menu.Data'); + case 'MODEL': + return t('data.Models'); + default: + return vfolderNode.metadata?.usageMode; + } + })(), + }, + { + key: 'created_at', + label: t('data.folders.CreatedAt'), + children: vfolderNode.metadata?.createdAt + ? dayjs(vfolderNode.metadata.createdAt).format('lll') + : '-', + }, + ]); + + return ( + + ); +}; + +export default VFolderNodeDescriptionV2; diff --git a/react/src/components/VFolderNodesV2.tsx b/react/src/components/VFolderNodesV2.tsx index 54c4cb17d5..6e40f42867 100644 --- a/react/src/components/VFolderNodesV2.tsx +++ b/react/src/components/VFolderNodesV2.tsx @@ -8,7 +8,7 @@ import { VFolderNodesV2Fragment$key, } from '../__generated__/VFolderNodesV2Fragment.graphql'; import { VFolderNodesV2RestoreMutation } from '../__generated__/VFolderNodesV2RestoreMutation.graphql'; -import { useSuspendedBackendaiClient } from '../hooks'; +import { useSuspendedBackendaiClient, useWebUINavigate } from '../hooks'; import { useCurrentUserInfo } from '../hooks/backendai'; import { useSuspenseTanQuery, useTanQuery } from '../hooks/reactQueryAlias'; import { useSetBAINotification } from '../hooks/useBAINotification'; @@ -50,7 +50,6 @@ import * as _ from 'lodash-es'; import React, { Suspense, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { graphql, useFragment, useMutation } from 'react-relay'; -import { useNavigate } from 'react-router-dom'; export const statusTagColor = { // V2 UPPERCASE enum values (VFolderOperationStatus) @@ -420,7 +419,7 @@ const VFolderNodesV2: React.FC = ({ const [currentUser] = useCurrentUserInfo(); const [inviteFolderId, setInviteFolderId] = useState(null); const { getErrorMessage } = useErrorMessageResolver(); - const navigate = useNavigate(); + const navigate = useWebUINavigate(); const { upsertNotification } = useSetBAINotification(); // Row-level hard-delete reuses the same modal as the bulk toolbar diff --git a/react/src/components/VirtualFolderNodeItems/VirtualFolderPathV2.tsx b/react/src/components/VirtualFolderNodeItems/VirtualFolderPathV2.tsx new file mode 100644 index 0000000000..b67b272707 --- /dev/null +++ b/react/src/components/VirtualFolderNodeItems/VirtualFolderPathV2.tsx @@ -0,0 +1,99 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { useVirtualFolderNodePathV2Fragment$key } from '../../__generated__/useVirtualFolderNodePathV2Fragment.graphql'; +import { useVirtualFolderPathV2 } from '../../hooks/useVirtualFolderNodePathV2'; +import { theme } from 'antd'; +import { BAIFlex, BAIText } from 'backend.ai-ui'; +import * as _ from 'lodash-es'; +import React from 'react'; + +interface VirtualFolderPathV2Props { + vfolderNodeFrgmt: useVirtualFolderNodePathV2Fragment$key; +} + +const VirtualFolderPathV2: React.FC = ({ + vfolderNodeFrgmt, +}) => { + const { + quotaScopeType, + quotaScopeIdWithoutType, + vfolderId, + vfolderIdPrefix1, + vfolderIdPrefix2, + vfolderIdRest, + } = useVirtualFolderPathV2(vfolderNodeFrgmt); + + const { token } = theme.useToken(); + + return ( + + + (root) + + + / + + + + {_.truncate(quotaScopeIdWithoutType.replaceAll('-', ''), { + length: 15, + })} + + + Quota Scope ID ({_.upperFirst(quotaScopeType)}) + + + + / + + + + + {vfolderIdPrefix1} + + + / + + + {vfolderIdPrefix2} + + + / + + + {_.truncate(vfolderIdRest.replaceAll('-', ''), { length: 7 })} + + + + VFolder ID + + + + ); +}; + +export default VirtualFolderPathV2; diff --git a/react/src/hooks/useVirtualFolderNodePathV2.ts b/react/src/hooks/useVirtualFolderNodePathV2.ts new file mode 100644 index 0000000000..be4e300b43 --- /dev/null +++ b/react/src/hooks/useVirtualFolderNodePathV2.ts @@ -0,0 +1,50 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { useVirtualFolderNodePathV2Fragment$key } from '../__generated__/useVirtualFolderNodePathV2Fragment.graphql'; +import { toLocalId } from 'backend.ai-ui'; +import * as _ from 'lodash-es'; +import { graphql, useFragment } from 'react-relay'; + +export const useVirtualFolderPathV2 = ( + vfolderNodeFrgmt: useVirtualFolderNodePathV2Fragment$key, +) => { + const vfolderNode = useFragment( + graphql` + fragment useVirtualFolderNodePathV2Fragment on VFolder { + id + metadata { + quotaScopeId + } + } + `, + vfolderNodeFrgmt, + ); + + // `quotaScopeId` is nullable on V2 `VFolder`, so split on an empty fallback + // to avoid `_.split(undefined, ':')`; default both destructured values to + // empty strings so downstream string operations (`.replaceAll`, etc.) never + // receive `undefined` when the source is missing a `:` delimiter. + const [quotaScopeType = '', quotaScopeIdWithoutType = ''] = _.split( + vfolderNode?.metadata?.quotaScopeId ?? '', + ':', + ); + const vfolderId = toLocalId(vfolderNode?.id); + const vfolderIdPrefix1 = vfolderId.slice(0, 2); + const vfolderIdPrefix2 = vfolderId.slice(2, 4); + const vfolderIdRest = vfolderId.slice(4); + const vfolderPath = quotaScopeIdWithoutType + ? `${quotaScopeIdWithoutType.replaceAll('-', '')}/${vfolderIdPrefix1}/${vfolderIdPrefix2}/${vfolderIdRest.replaceAll('-', '')}` + : ''; + + return { + quotaScopeType, + quotaScopeIdWithoutType, + vfolderId, + vfolderIdPrefix1, + vfolderIdPrefix2, + vfolderIdRest, + vfolderPath, + }; +}; diff --git a/react/src/pages/EndpointDetailPage.tsx b/react/src/pages/EndpointDetailPage.tsx index a6069e9e3f..16e4421ae3 100644 --- a/react/src/pages/EndpointDetailPage.tsx +++ b/react/src/pages/EndpointDetailPage.tsx @@ -32,7 +32,7 @@ import InferenceSessionErrorModal from '../components/InferenceSessionErrorModal import SessionDetailDrawer from '../components/SessionDetailDrawer'; import SourceCodeView from '../components/SourceCodeView'; import SwitchToProjectButton from '../components/SwitchToProjectButton'; -import VFolderLazyView from '../components/VFolderLazyView'; +import VFolderLazyViewV2 from '../components/VFolderLazyViewV2'; import { baiSignedRequestWithPromise, convertToOrderBy } from '../helper'; import { useSuspendedBackendaiClient, useWebUINavigate } from '../hooks'; import { useCurrentUserInfo } from '../hooks/backendai'; @@ -628,7 +628,7 @@ const EndpointDetailPage: React.FC = () => { children: endpoint?.model ? ( } />}> - + {endpoint?.model_mount_destination && (