diff --git a/packages/backend.ai-ui/src/components/Table/BAINameActionCell.tsx b/packages/backend.ai-ui/src/components/Table/BAINameActionCell.tsx index 1b5d83bbf4..3a56b3c9c1 100644 --- a/packages/backend.ai-ui/src/components/Table/BAINameActionCell.tsx +++ b/packages/backend.ai-ui/src/components/Table/BAINameActionCell.tsx @@ -3,8 +3,8 @@ import BAIButton from '../BAIButton'; import BAILink from '../BAILink'; import BAIText from '../BAIText'; import { MoreOutlined } from '@ant-design/icons'; -import { Dropdown, theme, Tooltip } from 'antd'; -import type { MenuProps } from 'antd'; +import { App, Dropdown, Popconfirm, theme, Tooltip } from 'antd'; +import type { MenuProps, PopconfirmProps } from 'antd'; import { createStyles } from 'antd-style'; import React, { useEffect, useRef, useState, useTransition } from 'react'; import type { LinkProps } from 'react-router-dom'; @@ -38,6 +38,19 @@ export interface BAINameActionCellAction { * - 'always': always shown only in the more menu */ showInMenu?: 'auto' | 'always'; + /** + * Ant Design Popconfirm props to gate the action behind a confirmation + * popover. When set, the visible icon button is wrapped with `` + * and the confirm action should be wired via `popConfirm.onConfirm`. + * + * When the action overflows into the more menu, the menu item falls back + * to a `Modal.confirm` dialog that mirrors the popConfirm title, + * description, okText, cancelText, and button props — so the + * confirmation UI is preserved in both visible and overflow states. + * If `onClick`/`action` is also set, those take precedence and the + * popConfirm is ignored in the overflow menu. + */ + popConfirm?: Omit; } export interface BAINameActionCellProps { @@ -156,6 +169,7 @@ const BAINameActionCell: React.FC = ({ 'use memo'; const { styles, cx } = useStyles(); const { token } = theme.useToken(); + const { modal } = App.useApp(); const [, startTransition] = useTransition(); const containerRef = useRef(null); const titleAreaRef = useRef(null); @@ -255,10 +269,40 @@ const BAINameActionCell: React.FC = ({ danger: action.type === 'danger', disabled: action.disabled, onClick: () => { - action.onClick?.(); - if (action.action) { - startTransition(async () => { - await action.action!(); + if (action.onClick || action.action) { + action.onClick?.(); + if (action.action) { + startTransition(async () => { + await action.action!(); + }); + } + return; + } + if (action.popConfirm) { + const { + title: confirmTitle, + description, + okText, + cancelText, + okButtonProps, + cancelButtonProps, + onConfirm, + onCancel, + } = action.popConfirm; + const resolveNode = ( + value: PopconfirmProps['title'] | PopconfirmProps['description'], + ): React.ReactNode => + typeof value === 'function' ? value() : (value ?? null); + modal.confirm({ + title: resolveNode(confirmTitle), + content: resolveNode(description), + okText, + cancelText, + okButtonProps, + cancelButtonProps, + okType: okButtonProps?.danger ? 'danger' : 'primary', + onOk: () => onConfirm?.(), + onCancel: () => onCancel?.(), }); } }, @@ -351,21 +395,29 @@ const BAINameActionCell: React.FC = ({ ? styles.actionButtonDanger : styles.actionButtonDefault; + const button = ( + + ); + return ( - + {action.popConfirm && !action.disabled ? ( + {button} + ) : ( + button + )} ); })} diff --git a/react/src/components/DeleteForeverVFolderModalV2.tsx b/react/src/components/DeleteForeverVFolderModalV2.tsx new file mode 100644 index 0000000000..113b8f9e68 --- /dev/null +++ b/react/src/components/DeleteForeverVFolderModalV2.tsx @@ -0,0 +1,154 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { DeleteForeverVFolderModalV2Fragment$key } from '../__generated__/DeleteForeverVFolderModalV2Fragment.graphql'; +import { DeleteForeverVFolderModalV2Mutation } from '../__generated__/DeleteForeverVFolderModalV2Mutation.graphql'; +import { Alert, App, theme, Typography } from 'antd'; +import { + BAIConfirmModalWithInput, + BAIConfirmModalWithInputProps, + BAIFlex, + toLocalId, + useErrorMessageResolver, +} from 'backend.ai-ui'; +import * as _ from 'lodash-es'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useFragment, useMutation } from 'react-relay'; + +interface DeleteForeverVFolderModalV2Props extends Omit< + BAIConfirmModalWithInputProps, + 'confirmText' | 'content' | 'title' | 'onOk' | 'onCancel' +> { + vfolderFrgmts?: DeleteForeverVFolderModalV2Fragment$key; + onRequestClose?: (success: boolean) => void; +} + +const DeleteForeverVFolderModalV2: React.FC< + DeleteForeverVFolderModalV2Props +> = ({ vfolderFrgmts, onRequestClose, ...modalProps }) => { + 'use memo'; + const { t } = useTranslation(); + const { message } = App.useApp(); + const { token } = theme.useToken(); + const { getErrorMessage } = useErrorMessageResolver(); + + const vfolders = useFragment( + graphql` + fragment DeleteForeverVFolderModalV2Fragment on VirtualFolderNode + @relay(plural: true) { + id + name + } + `, + vfolderFrgmts, + ); + + const [commitBulkPurgeMutation, isInFlightBulkPurge] = + useMutation(graphql` + mutation DeleteForeverVFolderModalV2Mutation( + $input: BulkPurgeVFoldersV2Input! + ) { + bulkPurgeVfoldersV2(input: $input) { + purgedCount + } + } + `); + + const purgeable = vfolders ?? []; + // For single-folder deletion the user must type the folder's own name — + // matches the BAIConfirmModalWithInput convention used elsewhere for + // irreversible per-resource actions. Bulk deletion falls back to a + // generic confirmation word since there is no single name to bind to. + const confirmText = + purgeable.length === 1 + ? (purgeable[0]?.name ?? t('data.folders.DeleteForeverConfirmText')) + : t('data.folders.DeleteForeverConfirmText'); + + return ( + + + + {purgeable.length === 1 + ? t('data.folders.DeleteForeverDescription', { + folderName: purgeable[0]?.name, + }) + : t('data.folders.DeleteForeverMultipleDescription', { + folderLength: purgeable.length, + })} + + + + {t('data.folders.TypeToConfirmDeleteForever')} + + ({confirmText}) + + + } + onOk={() => { + if (purgeable.length === 0) { + onRequestClose?.(false); + return; + } + const ids = _.map(purgeable, (vfolder) => toLocalId(vfolder.id)); + commitBulkPurgeMutation({ + variables: { input: { ids } }, + onCompleted: (data, errors) => { + if (errors && errors.length > 0) { + const firstError = errors[0]; + message.error(firstError?.message ?? getErrorMessage(firstError)); + return; + } + const purgedCount = data?.bulkPurgeVfoldersV2?.purgedCount ?? 0; + if (purgedCount === 0) { + message.error( + t('data.folders.FailedToDeleteFolders', { + folderNames: _.map(purgeable, 'name').join(', '), + }), + ); + return; + } + if (purgeable.length === 1) { + message.success( + t('data.folders.FolderDeletedForever', { + folderName: purgeable[0]?.name, + }), + ); + } else { + message.success( + t('data.folders.MultipleFolderDeletedForever', { + count: purgedCount, + total: purgeable.length, + }), + ); + } + onRequestClose?.(true); + }, + onError: (error) => { + message.error(getErrorMessage(error)); + }, + }); + }} + onCancel={() => onRequestClose?.(false)} + /> + ); +}; + +export default DeleteForeverVFolderModalV2; diff --git a/react/src/components/DeleteVFolderModalV2.tsx b/react/src/components/DeleteVFolderModalV2.tsx new file mode 100644 index 0000000000..772b74f6ea --- /dev/null +++ b/react/src/components/DeleteVFolderModalV2.tsx @@ -0,0 +1,161 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { DeleteVFolderModalV2Fragment$key } from '../__generated__/DeleteVFolderModalV2Fragment.graphql'; +import { DeleteVFolderModalV2Mutation } from '../__generated__/DeleteVFolderModalV2Mutation.graphql'; +import { App, Typography, theme } from 'antd'; +import { + BAIAlert, + BAIFlex, + BAIModal, + BAIModalProps, + toLocalId, + useErrorMessageResolver, +} from 'backend.ai-ui'; +import * as _ from 'lodash-es'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useFragment, useMutation } from 'react-relay'; + +interface DeleteVFolderModalV2Props extends BAIModalProps { + vfolderFrgmts?: DeleteVFolderModalV2Fragment$key; + onRequestClose?: (success: boolean) => void; +} + +const DeleteVFolderModalV2: React.FC = ({ + vfolderFrgmts, + onRequestClose, + ...baiModalProps +}) => { + 'use memo'; + const { t } = useTranslation(); + const { message } = App.useApp(); + const { getErrorMessage } = useErrorMessageResolver(); + const { token } = theme.useToken(); + + const vfolders = useFragment( + graphql` + fragment DeleteVFolderModalV2Fragment on VirtualFolderNode + @relay(plural: true) { + id + name + permissions + } + `, + vfolderFrgmts, + ); + + const [commitBulkDeleteMutation, isInFlightBulkDelete] = + useMutation(graphql` + mutation DeleteVFolderModalV2Mutation( + $input: BulkDeleteVFoldersV2Input! + ) { + bulkDeleteVfoldersV2(input: $input) { + deletedCount + } + } + `); + + const foldersByPermission = _.groupBy(vfolders, (vfolder) => { + if (vfolder.permissions?.includes('delete_vfolder')) { + return 'deletable'; + } + return 'undeletable'; + }); + + return ( + onRequestClose?.(false)} + onOk={() => { + const deletable = foldersByPermission.deletable ?? []; + if (deletable.length === 0) { + onRequestClose?.(false); + return; + } + const ids = _.map(deletable, (vfolder) => toLocalId(vfolder.id)); + commitBulkDeleteMutation({ + variables: { input: { ids } }, + onCompleted: (data, errors) => { + if (errors && errors.length > 0) { + const firstError = errors[0]; + message.error(firstError?.message ?? getErrorMessage(firstError)); + return; + } + const deletedCount = data?.bulkDeleteVfoldersV2?.deletedCount ?? 0; + if (deletedCount === 0) { + message.error( + t('data.folders.FailedToDeleteFolders', { + folderNames: _.map(deletable, 'name').join(', '), + }), + ); + return; + } + if (deletable.length === 1) { + message.success( + t('data.folders.FolderDeleted', { + folderName: deletable[0]?.name, + }), + ); + } else { + message.success( + t('data.folders.MultipleFolderDeleted', { + count: deletedCount, + total: deletable.length, + }), + ); + } + onRequestClose?.(true); + }, + onError: (error) => { + message.error(getErrorMessage(error)); + }, + }); + }} + {...baiModalProps} + > + + {vfolders && + vfolders.length !== foldersByPermission.deletable?.length && ( + + {_.map(foldersByPermission.undeletable, (vfolder) => ( +
  • {vfolder.name}
  • + ))} + + } + /> + )} + + {foldersByPermission.deletable?.length === 1 + ? t('data.folders.MoveToTrashDescription', { + folderName: foldersByPermission.deletable?.[0]?.name, + }) + : t('data.folders.MoveToTrashMultipleDescription', { + folderLength: foldersByPermission.deletable?.length, + })} + +
    +
    + ); +}; + +export default DeleteVFolderModalV2; diff --git a/react/src/components/RestoreVFolderModalV2.tsx b/react/src/components/RestoreVFolderModalV2.tsx new file mode 100644 index 0000000000..4cafe2e448 --- /dev/null +++ b/react/src/components/RestoreVFolderModalV2.tsx @@ -0,0 +1,132 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { RestoreVFolderModalV2Fragment$key } from '../__generated__/RestoreVFolderModalV2Fragment.graphql'; +import { RestoreVFolderModalV2Mutation } from '../__generated__/RestoreVFolderModalV2Mutation.graphql'; +import { useSetBAINotification } from '../hooks/useBAINotification'; +import { Typography, message } from 'antd'; +import { + BAIModal, + BAIModalProps, + toLocalId, + useErrorMessageResolver, +} from 'backend.ai-ui'; +import * as _ from 'lodash-es'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useFragment, useRelayEnvironment } from 'react-relay'; +import { commitMutation } from 'relay-runtime'; + +interface RestoreVFolderModalV2Props extends BAIModalProps { + vfolderFrgmts?: RestoreVFolderModalV2Fragment$key; + onRequestClose?: (success: boolean) => void; +} + +const RestoreVFolderModalV2: React.FC = ({ + vfolderFrgmts, + onRequestClose, + ...baiModalProps +}) => { + 'use memo'; + const { t } = useTranslation(); + const { upsertNotification } = useSetBAINotification(); + const { getErrorMessage } = useErrorMessageResolver(); + const environment = useRelayEnvironment(); + const [isRestoring, setIsRestoring] = useState(false); + + const vfolders = useFragment( + graphql` + fragment RestoreVFolderModalV2Fragment on VirtualFolderNode + @relay(plural: true) { + id + name + } + `, + vfolderFrgmts, + ); + + // TODO: replace this fan-out with a single bulk mutation once the manager + // exposes one. Today only a per-folder `restoreVFolder` mutation exists, so + // bulk restore commits one mutation per folder and aggregates results, + // mirroring the legacy modal. + const restoreSingle = (id: string) => + new Promise((resolve, reject) => { + commitMutation(environment, { + mutation: graphql` + mutation RestoreVFolderModalV2Mutation($vfolderId: UUID!) { + restoreVFolder(vfolderId: $vfolderId) { + id + } + } + `, + variables: { vfolderId: toLocalId(id) }, + onCompleted: (_data, errors) => { + if (errors && errors.length > 0) { + reject(errors[0]); + return; + } + resolve(); + }, + onError: (error) => reject(error), + }); + }); + + return ( + onRequestClose?.(false)} + onOk={() => { + const promises = _.map(vfolders, (vfolder) => + restoreSingle(vfolder.id).catch((error) => { + upsertNotification({ + message: getErrorMessage(error), + description: error?.description, + open: true, + }); + return Promise.reject(error); + }), + ); + setIsRestoring(true); + Promise.allSettled(promises).then((results) => { + setIsRestoring(false); + const success = results.every( + (result) => result.status === 'fulfilled', + ); + if (success) { + if (vfolders?.length === 1) { + message.success( + t('data.folders.FolderRestored', { + folderName: vfolders?.[0]?.name, + }), + ); + } else { + message.success( + t('data.folders.MultipleFolderRestored', { + folderLength: vfolders?.length, + }), + ); + } + } + onRequestClose?.(success); + }); + }} + {...baiModalProps} + > + + {vfolders?.length === 1 + ? t('data.folders.RestoreDescription', { + folderName: vfolders?.[0]?.name, + }) + : t('data.folders.RestoreMultipleDescription', { + folderLength: vfolders?.length, + })} + + + ); +}; + +export default RestoreVFolderModalV2; diff --git a/react/src/components/VFolderNodeDescription.tsx b/react/src/components/VFolderNodeDescription.tsx index e46f1380a4..2343c67876 100644 --- a/react/src/components/VFolderNodeDescription.tsx +++ b/react/src/components/VFolderNodeDescription.tsx @@ -11,7 +11,7 @@ import { useCurrentUserInfo } from '../hooks/backendai'; import { useTanMutation } from '../hooks/reactQueryAlias'; import { useCurrentProjectValue } from '../hooks/useCurrentProject'; import { useVirtualFolderPath } from '../hooks/useVirtualFolderNodePath'; -import { statusTagColor } from './VFolderNodes'; +import { statusTagColor } from './VFolderNodesV2'; import VirtualFolderPath from './VirtualFolderNodeItems/VirtualFolderPath'; import { CheckCircleOutlined, UserOutlined } from '@ant-design/icons'; import { diff --git a/react/src/components/VFolderNodesV2.tsx b/react/src/components/VFolderNodesV2.tsx new file mode 100644 index 0000000000..9364336ab5 --- /dev/null +++ b/react/src/components/VFolderNodesV2.tsx @@ -0,0 +1,617 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { VFolderNodesV2DeleteMutation } from '../__generated__/VFolderNodesV2DeleteMutation.graphql'; +import { + VFolderNodesV2Fragment$data, + VFolderNodesV2Fragment$key, +} from '../__generated__/VFolderNodesV2Fragment.graphql'; +import { VFolderNodesV2RestoreMutation } from '../__generated__/VFolderNodesV2RestoreMutation.graphql'; +import { useWebUINavigate } from '../hooks'; +import { useCurrentUserInfo } from '../hooks/backendai'; +import { useSetBAINotification } from '../hooks/useBAINotification'; +import { isDeletedCategory } from '../pages/VFolderNodeListPage'; +import DeleteForeverVFolderModalV2 from './DeleteForeverVFolderModalV2'; +import { useFolderExplorerOpener } from './FolderExplorerOpener'; +import InviteFolderSettingModal from './InviteFolderSettingModal'; +import SharedFolderPermissionInfoModal from './SharedFolderPermissionInfoModal'; +import VFolderDeployModal from './VFolderDeployModal'; +import VFolderNodeIdenticon from './VFolderNodeIdenticon'; +import VFolderPermissionCell from './VFolderPermissionCell'; +import { UserOutlined } from '@ant-design/icons'; +import { App, theme, Typography } from 'antd'; +import { + filterOutNullAndUndefined, + BAIEndpointsIcon, + BAILink, + BAIRestoreIcon, + BAIShareAltIcon, + BAITrashBinIcon, + BAIUserUnionIcon, + BAITable, + BAITableProps, + BAIFlex, + BAINameActionCell, + BAIText, + toLocalId, + useErrorMessageResolver, + BAITag, + bytesToGB, +} from 'backend.ai-ui'; +import type { BAINameActionCellAction } from 'backend.ai-ui'; +import dayjs from 'dayjs'; +import * as _ from 'lodash-es'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useFragment, useMutation } from 'react-relay'; + +export const statusTagColor = { + // mountable + ready: 'warning', + performing: 'warning', + cloning: 'warning', + mounted: 'warning', + // delete + 'delete-pending': 'default', + 'delete-ongoing': 'default', + 'delete-complete': 'default', + // error + error: 'error', + 'delete-error': 'error', +}; + +export type VFolderNodeInList = NonNullable< + VFolderNodesV2Fragment$data[number] +>; + +const availableVFolderSorterKeys = [ + 'name', + 'host', + 'quota_scope_id', + 'usage_mode', + 'ownership_type', + 'max_files', + 'max_size', + 'created_at', + 'last_used', + 'cloneable', + 'status', + 'cur_size', +] as const; + +const isEnableSorter = (key: string) => { + return _.includes(availableVFolderSorterKeys, key); +}; + +interface VFolderNameCellProps { + vfolder: VFolderNodeInList; + onShare: () => void; + onDelete: () => void; + onRestore: () => void; + onDeleteForever: () => void; + /** + * Called when the definition-check step on "Start Service" raises a + * warning (e.g. missing service-definition.toml or ambiguous runtime + * variants). The parent uses this to open the preset-selection modal + * (FR-2599) for the given vfolder instead of navigating away. + */ + onStartServiceFallback: (vfolderId: string) => void; +} + +const VFolderNameCell: React.FC = ({ + vfolder, + onShare, + onDelete, + onRestore, + onDeleteForever, + onStartServiceFallback, +}) => { + 'use memo'; + const { t } = useTranslation(); + const { token } = theme.useToken(); + const { generateFolderPath } = useFolderExplorerOpener(); + + const isPipelineFolder = vfolder?.usage_mode === 'data'; + const isModelFolder = vfolder?.usage_mode === 'model'; + const isDeleted = isDeletedCategory(vfolder?.status); + const hasDeletePermission = _.includes( + vfolder?.permissions, + 'delete_vfolder', + ); + + const vfolderId = toLocalId(vfolder.id ?? ''); + + const actions: BAINameActionCellAction[] = filterOutNullAndUndefined([ + // Start Service (model folders only, active only) + isModelFolder && !isDeleted + ? { + key: 'start-service', + title: t('modelService.DeployAsService'), + icon: , + onClick: () => onStartServiceFallback(vfolderId), + } + : null, + // Share (active folders only) + !isDeleted + ? { + key: 'share', + title: t('button.Share'), + icon: , + onClick: onShare, + } + : null, + // Move to trash (active folders only) + !isDeleted + ? { + key: 'delete', + title: t('data.folders.MoveToTrash'), + icon: , + type: 'danger' as const, + disabled: !hasDeletePermission || isPipelineFolder, + disabledReason: isPipelineFolder + ? t('data.folders.CannotDeletePipelineFolder') + : t('data.folders.NoDeletePermission'), + popConfirm: { + title: t('data.folders.MoveToTrash'), + description: t('data.folders.MoveToTrashRestoreHint'), + okText: t('button.Confirm'), + okButtonProps: { danger: true }, + onConfirm: onDelete, + }, + } + : null, + // Restore (deleted folders only) + isDeleted + ? { + key: 'restore', + title: t('data.folders.Restore'), + icon: , + disabled: vfolder?.status !== 'delete-pending' || isPipelineFolder, + disabledReason: isPipelineFolder + ? t('data.folders.CannotRestorePipelineFolder') + : undefined, + onClick: onRestore, + } + : null, + // Delete from trash bin (deleted folders only) + isDeleted + ? { + key: 'delete-forever', + title: t('data.folders.Delete'), + icon: , + type: 'danger' as const, + disabled: vfolder?.status !== 'delete-pending', + onClick: onDeleteForever, + } + : null, + ]); + + return ( + + } + title={vfolder.name} + to={generateFolderPath(vfolderId)} + actions={actions} + showActions="always" + /> + ); +}; + +interface VFolderNodesV2Props extends Omit< + BAITableProps, + 'dataSource' | 'columns' +> { + vfoldersFrgmt: VFolderNodesV2Fragment$key; + // Callback when a row is removed from current list + onRemoveRow?: (updatedFolderId?: string) => void; +} + +const VFolderNodesV2: React.FC = ({ + vfoldersFrgmt, + onRemoveRow, + ...tableProps +}) => { + 'use memo'; + const { t } = useTranslation(); + const { token } = theme.useToken(); + const { message } = App.useApp(); + const [currentUser] = useCurrentUserInfo(); + const [inviteFolderId, setInviteFolderId] = useState(null); + const { upsertNotification } = useSetBAINotification(); + const { getErrorMessage } = useErrorMessageResolver(); + const navigate = useWebUINavigate(); + + // Row-level hard-delete reuses the same modal as the bulk toolbar + // (typed-input confirmation is required for irreversible deletion). Soft + // delete is handled inline via the row action's Popconfirm — no per-row + // state needed here. + const [purgingVFolders, setPurgingVFolders] = useState< + Array + >([]); + const [currentSharedVFolder, setCurrentSharedVFolder] = + useState(null); + // vfolder id whose preset-selection deploy modal (FR-2599) should be open. + const [deployFallbackVfolderId, setDeployFallbackVfolderId] = useState< + string | null + >(null); + + const vfolders = useFragment( + graphql` + fragment VFolderNodesV2Fragment on VirtualFolderNode + @relay(plural: true) { + id @required(action: NONE) + status + name + host + quota_scope_id + ownership_type + user + user_email + group + group_name + usage_mode + max_files + max_size + created_at + last_used + num_files + cur_size + cloneable + permissions @since(version: "24.09.0") + ...VFolderPermissionCellFragment + ...VFolderNodeIdenticonFragment + ...SharedFolderPermissionInfoModalFragment + ...BAINodeNotificationItemFragment + ...DeleteForeverVFolderModalV2Fragment + } + `, + vfoldersFrgmt, + ); + + const filteredVFolders = filterOutNullAndUndefined(vfolders); + + const [commitDeleteMutation] = useMutation( + graphql` + mutation VFolderNodesV2DeleteMutation($vfolderId: UUID!) { + deleteVfolderV2(vfolderId: $vfolderId) { + id + } + } + `, + ); + + const [commitRestoreMutation] = useMutation( + graphql` + mutation VFolderNodesV2RestoreMutation($vfolderId: UUID!) { + restoreVFolder(vfolderId: $vfolderId) { + id + } + } + `, + ); + + // Backend reports "vfolder is occupied by sessions(ids: ['s1', 's2'])" + // when a folder is mounted in active sessions. Surface those session IDs + // as clickable links in the notification so the user can jump to them. + const handleDeleteError = (vfolder: VFolderNodeInList, error: Error) => { + const matchString = error?.message.match(/sessions\(ids: (\[.*?\])\)/)?.[1]; + const occupiedSession = JSON.parse(matchString?.replace(/'/g, '"') || '[]'); + upsertNotification({ + open: true, + key: `vfolder-error-${vfolder?.id}`, + node: vfolder, + description: getErrorMessage(error).replace(/\(ids[\s\S]*$/, ''), + extraDescription: !_.isEmpty(occupiedSession) ? ( + + + {t('data.folders.MountedSessions')} + + {_.map(occupiedSession, (sessionId) => ( + { + navigate({ + pathname: '/session', + search: new URLSearchParams({ + sessionDetail: sessionId, + }).toString(), + }); + }} + > + {sessionId} + + ))} + + ) : null, + }); + }; + + return ( + <> + record.id} + size="small" + dataSource={filteredVFolders} + scroll={{ x: 'max-content' }} + columns={[ + { + key: 'name', + title: t('data.folders.Name'), + dataIndex: 'name', + required: true, + render: (_name, vfolder) => { + return ( + { + vfolder?.user === currentUser?.uuid + ? setInviteFolderId(toLocalId(vfolder?.id ?? null)) + : setCurrentSharedVFolder(vfolder); + }} + onDelete={() => { + const folderId = vfolder?.id; + if (!folderId) return; + commitDeleteMutation({ + variables: { vfolderId: toLocalId(folderId) }, + onCompleted: (_data, errors) => { + if (errors && errors.length > 0) { + handleDeleteError( + vfolder, + new Error(errors[0]?.message ?? ''), + ); + return; + } + onRemoveRow?.(folderId); + message.success( + t('data.folders.MovedToTrashBin', { + folderName: vfolder?.name, + }), + ); + }, + onError: (error) => handleDeleteError(vfolder, error), + }); + }} + onRestore={() => { + const folderId = vfolder?.id; + if (!folderId) return; + const handleError = (error: Error) => { + upsertNotification({ + key: `vfolder-error-${folderId}`, + node: vfolder, + description: getErrorMessage(error), + open: true, + }); + }; + commitRestoreMutation({ + variables: { vfolderId: toLocalId(folderId) }, + onCompleted: (_data, errors) => { + if (errors && errors.length > 0) { + handleError(new Error(errors[0]?.message ?? '')); + return; + } + onRemoveRow?.(folderId); + message.success( + t('data.folders.FolderRestored', { + folderName: vfolder?.name, + }), + ); + }, + onError: handleError, + }); + }} + onDeleteForever={() => { + setPurgingVFolders(vfolder ? [vfolder] : []); + }} + onStartServiceFallback={(id) => { + setDeployFallbackVfolderId(id); + }} + /> + ); + }, + sorter: isEnableSorter('name'), + }, + { + key: 'status', + title: t('data.folders.Status'), + dataIndex: 'status', + render: (status: string) => { + return ( + + {_.toUpper(status)} + + ); + }, + sorter: isEnableSorter('status'), + }, + { + key: 'host', + title: t('data.folders.Location'), + dataIndex: 'host', + sorter: isEnableSorter('host'), + }, + { + key: 'permissions', + title: t('data.folders.MountPermission'), + render: (_perm: string, vfolder) => { + return ; + }, + }, + { + key: 'ownership_type', + title: t('data.folders.Type'), + dataIndex: 'ownership_type', + render: (type: string) => { + return type === 'user' ? ( + + {t('data.User')} + + + ) : ( + + {t('data.Project')} + + + ); + }, + sorter: isEnableSorter('ownership_type'), + }, + + { + key: 'owner', + title: t('data.folders.Owner'), + render: (__, vfolder) => + vfolder.ownership_type === 'user' + ? vfolder?.user_email + : vfolder?.group_name, + }, + { + key: 'usage_mode', + title: t('data.UsageMode'), + dataIndex: 'usage_mode', + defaultHidden: true, + sorter: isEnableSorter('usage_mode'), + render: (mode: string) => { + switch (mode) { + case 'general': + return t('data.General'); + case 'data': + return t('webui.menu.Data'); + case 'model': + return t('data.Models'); + default: + return mode; + } + }, + }, + { + key: 'num_files', + title: t('data.folders.NumberOfFiles'), + dataIndex: 'num_files', + defaultHidden: true, + sorter: isEnableSorter('num_files'), + render: (value: number) => + value != null ? value.toLocaleString() : '-', + }, + { + key: 'cur_size', + title: t('data.folders.FolderUsage'), + dataIndex: 'cur_size', + defaultHidden: true, + sorter: isEnableSorter('cur_size'), + render: (value: string) => + value != null ? `${bytesToGB(Number(value))} GB` : '-', + }, + { + key: 'max_files', + title: t('data.folders.MaxFolderQuota'), + dataIndex: 'max_files', + defaultHidden: true, + sorter: isEnableSorter('max_files'), + render: (value: number) => + value != null && value > 0 ? value.toLocaleString() : '-', + }, + { + key: 'max_size', + title: t('data.folders.MaxSize'), + dataIndex: 'max_size', + defaultHidden: true, + sorter: isEnableSorter('max_size'), + render: (value: string) => + value != null && Number(value) > 0 + ? `${bytesToGB(Number(value))} GB` + : '-', + }, + { + key: 'cloneable', + title: t('data.folders.Cloneable'), + dataIndex: 'cloneable', + defaultHidden: true, + sorter: isEnableSorter('cloneable'), + render: (value: boolean) => + value ? t('button.Yes') : t('button.No'), + }, + { + key: 'quota_scope_id', + title: t('data.QuotaScopeId'), + dataIndex: 'quota_scope_id', + defaultHidden: true, + sorter: isEnableSorter('quota_scope_id'), + render: (value: string) => + value ? {value} : '-', + }, + { + key: 'last_used', + title: t('credential.LastUsed'), + dataIndex: 'last_used', + defaultHidden: true, + sorter: isEnableSorter('last_used'), + render: (value: string) => + value ? dayjs(value).format('ll LT') : '-', + }, + { + key: 'created_at', + title: t('data.folders.CreatedAt'), + dataIndex: 'created_at', + defaultHidden: true, + sorter: isEnableSorter('created_at'), + render: (value: string) => + value ? dayjs(value).format('ll LT') : '-', + }, + ]} + {...tableProps} + /> + 0} + onRequestClose={(success) => { + if (success) { + purgingVFolders.forEach((v) => onRemoveRow?.(v.id)); + } + setPurgingVFolders([]); + }} + /> + { + setInviteFolderId(null); + }} + vfolderId={inviteFolderId} + open={!!inviteFolderId} + /> + { + onRemoveRow?.(id); + }} + onRequestClose={() => { + setCurrentSharedVFolder(null); + }} + /> + setDeployFallbackVfolderId(null)} + onDeployed={() => setDeployFallbackVfolderId(null)} + /> + + ); +}; + +export default VFolderNodesV2; diff --git a/react/src/pages/AdminVFolderNodeListPage.tsx b/react/src/pages/AdminVFolderNodeListPage.tsx index 72c3704b6b..7c214707c0 100644 --- a/react/src/pages/AdminVFolderNodeListPage.tsx +++ b/react/src/pages/AdminVFolderNodeListPage.tsx @@ -9,10 +9,13 @@ import type { } from '../__generated__/AdminVFolderNodeListPageQuery.graphql'; import BAIRadioGroup from '../components/BAIRadioGroup'; import BAITabs from '../components/BAITabs'; -import DeleteVFolderModal from '../components/DeleteVFolderModal'; +import DeleteForeverVFolderModalV2 from '../components/DeleteForeverVFolderModalV2'; +import DeleteVFolderModalV2 from '../components/DeleteVFolderModalV2'; import FolderCreateModalV2 from '../components/FolderCreateModalV2'; -import RestoreVFolderModal from '../components/RestoreVFolderModal'; -import VFolderNodes, { VFolderNodeInList } from '../components/VFolderNodes'; +import RestoreVFolderModalV2 from '../components/RestoreVFolderModalV2'; +import VFolderNodesV2, { + VFolderNodeInList, +} from '../components/VFolderNodesV2'; import { handleRowSelectionChange } from '../helper'; import { useSuspendedBackendaiClient } from '../hooks'; import { isDeletedCategory } from './VFolderNodeListPage'; @@ -24,6 +27,7 @@ import { BAIFetchKeyButton, BAIFlex, BAIPropertyFilter, + BAIPurgeIcon, BAIRestoreIcon, BAISelectionLabel, BAIVFolderDeleteButton, @@ -81,6 +85,8 @@ const AdminVFolderNodeListPage: React.FC = (props) => { const [isOpenDeleteModal, { toggle: toggleDeleteModal }] = useToggle(false); const [isOpenRestoreModal, { toggle: toggleRestoreModal }] = useToggle(false); const [isOpenCreateModal, { toggle: toggleCreateModal }] = useToggle(false); + const [isOpenDeleteForeverModal, { toggle: toggleDeleteForeverModal }] = + useToggle(false); const { baiPaginationOption, @@ -171,10 +177,11 @@ const AdminVFolderNodeListPage: React.FC = (props) => { id @required(action: THROW) status permissions - ...VFolderNodesFragment - ...DeleteVFolderModalFragment + ...VFolderNodesV2Fragment + ...DeleteVFolderModalV2Fragment + ...DeleteForeverVFolderModalV2Fragment ...EditableVFolderNameFragment - ...RestoreVFolderModalFragment + ...RestoreVFolderModalV2Fragment ...VFolderNodeIdenticonFragment ...SharedFolderPermissionInfoModalFragment ...BAIVFolderDeleteButtonFragment @@ -432,6 +439,20 @@ const AdminVFolderNodeListPage: React.FC = (props) => { }} /> + + } + onClick={() => { + toggleDeleteForeverModal(); + }} + /> + )} {
    - { )} rowSelection={{ type: 'checkbox', - // Preserve selected rows between pages, but clear when filter changes preserveSelectedRowKeys: true, getCheckboxProps(record: VFolderNodeInList) { return { @@ -474,7 +494,6 @@ const AdminVFolderNodeListPage: React.FC = (props) => { }; }, onChange: (selectedRowKeys) => { - // Using selectedRowKeys to retrieve selected rows since selectedRows lack nested fragment types handleRowSelectionChange( selectedRowKeys, filterOutNullAndUndefined( @@ -511,7 +530,7 @@ const AdminVFolderNodeListPage: React.FC = (props) => { /> - { @@ -522,7 +541,7 @@ const AdminVFolderNodeListPage: React.FC = (props) => { toggleDeleteModal(); }} /> - { @@ -533,6 +552,17 @@ const AdminVFolderNodeListPage: React.FC = (props) => { toggleRestoreModal(); }} /> + { + if (success) { + updateFetchKey(); + setSelectedFolderList([]); + } + toggleDeleteForeverModal(); + }} + /> = ({ const [isOpenCreateModal, { toggle: toggleCreateModal }] = useToggle(false); const [isOpenDeleteModal, { toggle: toggleDeleteModal }] = useToggle(false); const [isOpenRestoreModal, { toggle: toggleRestoreModal }] = useToggle(false); + const [isOpenDeleteForeverModal, { toggle: toggleDeleteForeverModal }] = + useToggle(false); const { baiPaginationOption, @@ -220,10 +218,11 @@ const VFolderNodeListPage: React.FC = ({ id @required(action: THROW) status permissions - ...VFolderNodesFragment - ...DeleteVFolderModalFragment + ...VFolderNodesV2Fragment + ...DeleteVFolderModalV2Fragment + ...DeleteForeverVFolderModalV2Fragment ...EditableVFolderNameFragment - ...RestoreVFolderModalFragment + ...RestoreVFolderModalV2Fragment ...VFolderNodeIdenticonFragment ...SharedFolderPermissionInfoModalFragment ...BAIVFolderDeleteButtonFragment @@ -395,14 +394,14 @@ const VFolderNodeListPage: React.FC = ({ variant="borderless" title={t('data.Folders')} extra={ - + } styles={{ header: { @@ -617,7 +616,7 @@ const VFolderNodeListPage: React.FC = ({ onClearSelection={() => setSelectedFolderList([])} /> -