diff --git a/data/schema.graphql b/data/schema.graphql index 7fa786903b..d0178a1e48 100644 --- a/data/schema.graphql +++ b/data/schema.graphql @@ -3794,11 +3794,19 @@ type Mutation """Added in 25.14.0""" deleteObjectStorage(input: DeleteObjectStorageInput!): DeleteObjectStoragePayload! @join__field(graph: STRAWBERRY) - """Added in 25.14.0""" - registerObjectStorageBucket(input: RegisterObjectStorageBucketInput!): RegisterObjectStorageBucketPayload! @join__field(graph: STRAWBERRY) + """ + Added in 25.15.0. + + Registers a new namespace within a storage. + """ + registerStorageNamespace(input: RegisterStorageNamespaceInput!): RegisterStorageNamespacePayload! @join__field(graph: STRAWBERRY) - """Added in 25.14.0""" - unregisterObjectStorageBucket(input: UnregisterObjectStorageBucketInput!): UnregisterObjectStorageBucketPayload! @join__field(graph: STRAWBERRY) + """ + Added in 25.15.0. + + Unregisters an existing namespace from a storage. + """ + unregisterStorageNamespace(input: UnregisterStorageNamespaceInput!): UnregisterStorageNamespacePayload! @join__field(graph: STRAWBERRY) """Added in 25.14.0""" createHuggingfaceRegistry(input: CreateHuggingFaceRegistryInput!): CreateHuggingFaceRegistryPayload! @join__field(graph: STRAWBERRY) @@ -3899,7 +3907,7 @@ type ObjectStorage implements Node secretKey: String! endpoint: String! region: String! - namespaces(before: String, after: String, first: Int, last: Int, limit: Int, offset: Int): ObjectStorageNamespaceConnection! + namespaces(before: String, after: String, first: Int, last: Int, limit: Int, offset: Int): StorageNamespaceConnection! } """Added in 25.14.0""" @@ -3925,40 +3933,6 @@ type ObjectStorageEdge node: ObjectStorage! } -"""Added in 25.14.0""" -type ObjectStorageNamespace implements Node - @join__implements(graph: STRAWBERRY, interface: "Node") - @join__type(graph: STRAWBERRY) -{ - """The Globally Unique ID of this object""" - id: ID! - storageId: ID! - bucket: String! -} - -"""Added in 25.14.0""" -type ObjectStorageNamespaceConnection - @join__type(graph: STRAWBERRY) -{ - """Pagination data for this connection""" - pageInfo: PageInfo! - - """Contains the nodes in this connection""" - edges: [ObjectStorageNamespaceEdge!]! - count: Int! -} - -"""An edge in a connection.""" -type ObjectStorageNamespaceEdge - @join__type(graph: STRAWBERRY) -{ - """A cursor for use in pagination""" - cursor: String! - - """The item at the end of the edge""" - node: ObjectStorageNamespace! -} - enum OrderDirection @join__type(graph: STRAWBERRY) { @@ -4605,16 +4579,24 @@ type RawServiceConfig extraCliParameters: String } -"""Added in 25.14.0""" -input RegisterObjectStorageBucketInput +""" +Added in 25.15.0. + +Input type for registering a storage namespace. +""" +input RegisterStorageNamespaceInput @join__type(graph: STRAWBERRY) { storageId: UUID! - bucketName: String! + namespace: String! } -"""Added in 25.14.0""" -type RegisterObjectStorageBucketPayload +""" +Added in 25.15.0. + +Payload returned after storage namespace registration. +""" +type RegisterStorageNamespacePayload @join__type(graph: STRAWBERRY) { id: UUID! @@ -5062,6 +5044,53 @@ type SourceInfo url: String } +""" +Added in 25.15.0. + +Storage namespace provides logical separation of data within a single storage system +to organize and isolate domain-specific concerns. + +Implementation varies by storage type: +- Object Storage (S3, MinIO): Uses bucket-based namespace separation +- File System (VFS): Uses directory path prefix for namespace distinction +""" +type StorageNamespace implements Node + @join__implements(graph: STRAWBERRY, interface: "Node") + @join__type(graph: STRAWBERRY) +{ + """The Globally Unique ID of this object""" + id: ID! + storageId: ID! + namespace: String! +} + +""" +Added in 25.15.0. + +Storage namespace connection for pagination. +""" +type StorageNamespaceConnection + @join__type(graph: STRAWBERRY) +{ + """Pagination data for this connection""" + pageInfo: PageInfo! + + """Contains the nodes in this connection""" + edges: [StorageNamespaceEdge!]! + count: Int! +} + +"""An edge in a connection.""" +type StorageNamespaceEdge + @join__type(graph: STRAWBERRY) +{ + """A cursor for use in pagination""" + cursor: String! + + """The item at the end of the edge""" + node: StorageNamespace! +} + type StorageVolume implements Item @join__implements(graph: GRAPHENE, interface: "Item") @join__type(graph: GRAPHENE) @@ -5135,16 +5164,24 @@ type UnloadImage task_id: String } -"""Added in 25.14.0""" -input UnregisterObjectStorageBucketInput +""" +Added in 25.15.0. + +Input type for unregistering a storage namespace. +""" +input UnregisterStorageNamespaceInput @join__type(graph: STRAWBERRY) { storageId: UUID! - bucketName: String! + namespace: String! } -"""Added in 25.14.0""" -type UnregisterObjectStorageBucketPayload +""" +Added in 25.15.0. + +Payload returned after storage namespace unregistration. +""" +type UnregisterStorageNamespacePayload @join__type(graph: STRAWBERRY) { id: UUID! diff --git a/packages/backend.ai-ui/src/components/Table/BAITable.tsx b/packages/backend.ai-ui/src/components/Table/BAITable.tsx index ff4eb0a026..9ed1255732 100644 --- a/packages/backend.ai-ui/src/components/Table/BAITable.tsx +++ b/packages/backend.ai-ui/src/components/Table/BAITable.tsx @@ -24,7 +24,7 @@ import { Resizable, ResizeCallbackData } from 'react-resizable'; * Configuration interface for BAITable pagination * Extends Ant Design's TablePaginationConfig but omits 'position' property */ -interface BAITablePaginationConfig +export interface BAITablePaginationConfig extends Omit { /** Additional content to display in the pagination area */ extraContent?: ReactNode; diff --git a/packages/backend.ai-ui/src/components/Table/BAITableSettingModal.tsx b/packages/backend.ai-ui/src/components/Table/BAITableSettingModal.tsx index 174ebe5b34..e4db45d14b 100644 --- a/packages/backend.ai-ui/src/components/Table/BAITableSettingModal.tsx +++ b/packages/backend.ai-ui/src/components/Table/BAITableSettingModal.tsx @@ -442,7 +442,7 @@ const BAITableSettingModal: React.FC = ({ style={{ height: 330, }} - scroll={{ x: 'max-content' }} + scroll={{ x: 'max-content', y: 330 }} /> diff --git a/packages/backend.ai-ui/src/components/Table/index.ts b/packages/backend.ai-ui/src/components/Table/index.ts index 6555b3a1d6..a17bc72ae4 100644 --- a/packages/backend.ai-ui/src/components/Table/index.ts +++ b/packages/backend.ai-ui/src/components/Table/index.ts @@ -4,6 +4,7 @@ export type { BAIColumnType, BAIColumnsType, BAITableSettings, + BAITablePaginationConfig, BAITableColumnOverrideItem, BAITableColumnOverrideRecord, } from './BAITable'; diff --git a/react/src/components/DeploymentList.tsx b/react/src/components/DeploymentList.tsx new file mode 100644 index 0000000000..8722c77b9b --- /dev/null +++ b/react/src/components/DeploymentList.tsx @@ -0,0 +1,235 @@ +import ResourceNumber from './ResourceNumber'; +import WebUILink from './WebUILink'; +import { CheckOutlined, CloseOutlined } from '@ant-design/icons'; +import { Tag, theme, Tooltip, Typography } from 'antd'; +import { + BAIColumnType, + BAIFlex, + BAITable, + BAITablePaginationConfig, + BAITableProps, + filterOutEmpty, + filterOutNullAndUndefined, + toLocalId, +} from 'backend.ai-ui'; +import dayjs from 'dayjs'; +import _ from 'lodash'; +import { ExternalLinkIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useFragment } from 'react-relay'; +import { graphql } from 'relay-runtime'; +import { + DeploymentListFragment$data, + DeploymentListFragment$key, +} from 'src/__generated__/DeploymentListFragment.graphql'; +import { useSuspendedBackendaiClient } from 'src/hooks'; + +type ModelDeployment = NonNullable< + NonNullable[number] +>; +interface DeploymentListProps + extends Omit, 'dataSource' | 'columns'> { + deploymentsFragment: DeploymentListFragment$key; + pagination: BAITablePaginationConfig; +} + +const DeploymentList: React.FC = ({ + deploymentsFragment, + pagination, + ...tableProps +}) => { + const { t } = useTranslation(); + const { token } = theme.useToken(); + const baiClient = useSuspendedBackendaiClient(); + + const deployments = useFragment( + graphql` + fragment DeploymentListFragment on ModelDeployment @relay(plural: true) { + id + metadata { + name + createdAt + updatedAt + tags + } + networkAccess { + endpointUrl + openToPublic + } + revision { + id + name + clusterConfig { + size + } + resourceConfig { + resourceSlots + resourceOpts + } + } + replicaState { + desiredReplicaCount + } + defaultDeploymentStrategy { + type + } + createdUser { + email + } + } + `, + deploymentsFragment, + ); + + const filteredDeployments = filterOutNullAndUndefined(deployments); + const columns = _.map( + filterOutEmpty>([ + { + title: t('deployment.DeploymentName'), + key: 'name', + dataIndex: ['metadata', 'name'], + fixed: 'left', + render: (name, row) => ( + {name} + ), + }, + { + title: t('deployment.EndpointURL'), + key: 'endpointUrl', + dataIndex: ['networkAccess', 'endpointUrl'], + render: (url) => ( + + {url ? ( + <> + {url} + + + + + + + + ) : ( + '-' + )} + + ), + }, + { + title: t('deployment.Public'), + key: 'openToPublic', + dataIndex: ['networkAccess', 'openToPublic'], + render: (openToPublic) => + openToPublic ? ( + + ) : ( + + ), + }, + { + title: t('deployment.Tags'), + dataIndex: ['metadata', 'tags'], + key: 'tags', + render: (tags) => _.map(tags, (tag) => {tag}), + }, + { + title: t('deployment.NumberOfDesiredReplicas'), + key: 'desiredReplicaCount', + dataIndex: ['replicaState', 'desiredReplicaCount'], + render: (count) => count, + defaultHidden: true, + }, + { + title: t('deployment.Resources'), + dataIndex: ['revision', 'resourceConfig', 'resourceSlots'], + key: 'resourceSlots', + render: (resourceSlots) => ( + + {_.map(JSON.parse(resourceSlots || '{}'), (value, key) => ( + + ))} + + ), + defaultHidden: true, + }, + { + title: t('deployment.ClusterSize'), + dataIndex: ['revision', 'clusterConfig', 'size'], + key: 'clusterSize', + render: (size) => {size}, + + defaultHidden: true, + }, + { + title: t('deployment.DefaultDeploymentStrategy'), + dataIndex: ['defaultDeploymentStrategy', 'type'], + key: 'type', + render: (type) => ( + + {type} + + ), + }, + { + title: t('deployment.RevisionName'), + dataIndex: ['revision', 'name'], + key: 'revisionName', + render: (name, row) => ( + {name} + ), + defaultHidden: true, + }, + { + title: t('deployment.CreatedAt'), + dataIndex: ['metadata', 'createdAt'], + key: 'createdAt', + render: (createdAt) => { + return dayjs(createdAt).format('ll LT'); + }, + }, + { + title: t('deployment.UpdatedAt'), + dataIndex: ['metadata', 'updatedAt'], + key: 'updatedAt', + render: (updatedAt) => { + return dayjs(updatedAt).format('ll LT'); + }, + defaultHidden: true, + }, + baiClient.is_admin && { + title: t('deployment.CreatedBy'), + dataIndex: ['createdUser', 'email'], + key: 'createdBy', + render: (email) => {email}, + }, + ]), + ); + + return ( + + ); +}; + +export default DeploymentList; diff --git a/react/src/pages/DeploymentListPage.tsx b/react/src/pages/DeploymentListPage.tsx new file mode 100644 index 0000000000..c92ca6eb99 --- /dev/null +++ b/react/src/pages/DeploymentListPage.tsx @@ -0,0 +1,203 @@ +import BAIFetchKeyButton from '../components/BAIFetchKeyButton'; +import DeploymentList from '../components/DeploymentList'; +import { INITIAL_FETCH_KEY, useFetchKey } from '../hooks'; +import { useBAIPaginationOptionStateOnSearchParam } from '../hooks/reactPaginationQueryOptions'; +import { useDeferredQueryParams } from '../hooks/useDeferredQueryParams'; +import { Button } from 'antd'; +import { + BAICard, + BAIFlex, + BAIGraphQLPropertyFilter, + filterOutNullAndUndefined, +} from 'backend.ai-ui'; +import _ from 'lodash'; +import React, { useDeferredValue, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useLazyLoadQuery } from 'react-relay'; +import { DeploymentListPageQuery } from 'src/__generated__/DeploymentListPageQuery.graphql'; +import { useBAISettingUserState } from 'src/hooks/useBAISetting'; +import { StringParam, withDefault } from 'use-query-params'; + +const DeploymentListPage: React.FC = () => { + const { t } = useTranslation(); + + const { tablePaginationOption, setTablePaginationOption } = + useBAIPaginationOptionStateOnSearchParam({ + current: 1, + pageSize: 10, + }); + + const [columnOverrides, setColumnOverrides] = useBAISettingUserState( + 'table_column_overrides.DeploymentListPage', + ); + + const [fetchKey, updateFetchKey] = useFetchKey(); + + const [queryParams, setQuery] = useDeferredQueryParams({ + order: withDefault(StringParam, '-created_at'), + filter: withDefault(StringParam, undefined), + }); + + const queryVariables = useMemo( + () => ({ + filter: queryParams.filter ? JSON.parse(queryParams.filter) : undefined, + first: tablePaginationOption.pageSize, + }), + [queryParams.filter, tablePaginationOption.pageSize], + ); + + const deferredQueryVariables = useDeferredValue(queryVariables); + const deferredFetchKey = useDeferredValue(fetchKey); + + const deployments = useLazyLoadQuery( + graphql` + query DeploymentListPageQuery($filter: DeploymentFilter, $first: Int) { + deployments(filter: $filter, first: $first) { + edges { + node { + id + ...DeploymentListFragment + } + } + count + } + } + `, + deferredQueryVariables, + { + fetchPolicy: + deferredFetchKey === INITIAL_FETCH_KEY + ? 'store-and-network' + : 'network-only', + fetchKey: + deferredFetchKey === INITIAL_FETCH_KEY ? undefined : deferredFetchKey, + }, + ); + + return ( + + {/* + + + {t('deployment.Deployments')} + + + */} + + + { + updateFetchKey(newFetchKey); + }} + /> + + + } + styles={{ + header: { + borderBottom: 'none', + }, + body: { + paddingTop: 0, + }, + }} + > + + + + { + setQuery( + { filter: value ? JSON.stringify(value) : undefined }, + 'replaceIn', + ); + setTablePaginationOption({ current: 1 }); + }} + /> + + + + edge?.node), + )} + loading={false} + pagination={{ + pageSize: tablePaginationOption.pageSize, + current: tablePaginationOption.current, + total: deployments?.deployments?.count || 0, + onChange: (current, pageSize) => { + if (_.isNumber(current) && _.isNumber(pageSize)) { + setTablePaginationOption({ current, pageSize }); + } + }, + }} + onChangeOrder={(order) => { + setQuery({ order }, 'replaceIn'); + }} + tableSettings={{ + columnOverrides: columnOverrides, + onColumnOverridesChange: setColumnOverrides, + }} + /> + + + + ); +}; + +export default DeploymentListPage; diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 8c8ea90bde..71577b1c2d 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -132,6 +132,9 @@ } } }, + "common": { + "OpenInNewTab": "Open in new tab" + }, "credential": { "AccessKey": "Access Key", "AccessKeyOptional": "Access Key (optional)", @@ -486,6 +489,68 @@ "Used": "used" } }, + "deployment": { + "Activate": "Activate", + "Active": "Active", + "ActiveReplicas": "Active Replicas", + "ActiveRevisions": "Active Revisions", + "CheckDomainAvailability": "Check domain availability", + "ClusterSize": "Cluster Size", + "CreateDeployment": "Create Deployment", + "CreatedAt": "Created At", + "CreatedBy": "Created By", + "CreatorEmail": "Creator Email", + "DefaultDeploymentStrategy": "Default Deployment Strategy", + "DeploymentDetail": "Deployment Detail", + "DeploymentInfo": "Deployment Info", + "DeploymentList": "Deployment List", + "DeploymentName": "Deployment Name", + "DeploymentNameMaxLength": "Deployment name must be less than 50 characters", + "DeploymentNameMinLength": "Deployment name must be at least 3 characters", + "DeploymentNamePattern": "Deployment name can only contain letters, numbers, hyphens, and underscores", + "DeploymentNamePlaceholder": "Enter deployment name", + "DeploymentNameRequired": "Deployment name is required", + "DeploymentStrategy": "Deployment Strategy", + "Deployments": "Deployments", + "Description": "Description", + "DescriptionPlaceholder": "Enter description for this deployment/revision", + "Domain": "Domain", + "DomainAlreadyExists": "This domain is already in use", + "DomainHelp": "If not provided, domain will be auto-generated", + "DomainPlaceholder": "api.example.com", + "EndpointURL": "Endpoint URL", + "ExpertMode": "Expert Mode", + "ExpertModeDescription": "Direct configuration based", + "ExpertModeTooltip": "Expert mode allows direct configuration of all deployment settings", + "Hibernate": "Hibernate", + "ImageName": "Image Name", + "Inactive": "Inactive", + "Mode": "Mode", + "ModeRequired": "Mode selection is required", + "ModeWarning": "Mode cannot be changed after creation", + "ModeWarningDescription": "Please choose carefully as this setting is permanent", + "Name": "Name", + "NumberOfDesiredReplicas": "Number of Desired Replicas", + "Owner": "Owner", + "PreferredDomainName": "Preferred Domain Name", + "Public": "Public", + "Replicas": "Replicas", + "Resources": "Resources", + "RevisionName": "Revision Name", + "Revisions": "Revisions", + "SimpleMode": "Simple Mode", + "SimpleModeDescription": "Preset based", + "SimpleModeTooltip": "Simple mode uses predefined presets for easy deployment", + "Status": "Status", + "Tags": "Tags", + "TokensLastHour": "Tokens (Last Hour)", + "TotalResources": "Total Resources", + "TrafficRatio": "Traffic Ratio", + "TrafficRatioEditDescription": "Total traffic ratio must equal 100%", + "TrafficRatioEditWarning": "Editing traffic ratios", + "URL": "URL", + "UpdatedAt": "Updated At" + }, "desktopNotification": { "NotSupported": "This browser does not support notifications.", "PermissionDenied": "You've denied notification access. To use alerts, please allow it in your browser settings."