diff --git a/packages/backend.ai-ui/src/components/Table/BAITable.tsx b/packages/backend.ai-ui/src/components/Table/BAITable.tsx index 688954033b..26ff6d4a8f 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/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 5b0f03042b..334f58c297 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -141,6 +141,9 @@ } } }, + "common": { + "OpenInNewTab": "Open in new tab" + }, "credential": { "AccessKey": "Access Key", "AccessKeyOptional": "Access Key (optional)", @@ -499,6 +502,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."