diff --git a/react/src/App.tsx b/react/src/App.tsx index 98b76919d6..ac61be4f63 100644 --- a/react/src/App.tsx +++ b/react/src/App.tsx @@ -60,8 +60,8 @@ const UserCredentialsPage = React.lazy( () => import('./pages/UserCredentialsPage'), ); -const ComputeSessionList = React.lazy( - () => import('./components/ComputeSessionList'), +const ComputeSessionListPage = React.lazy( + () => import('./pages/ComputeSessionListPage'), ); const AgentSummaryPage = React.lazy(() => import('./pages/AgentSummaryPage')); @@ -142,7 +142,7 @@ const router = createBrowserRouter([ handle: { labelKey: 'webui.menu.Sessions' }, element: ( <BAIErrorBoundary> - <ComputeSessionList /> + <ComputeSessionListPage /> </BAIErrorBoundary> ), }, diff --git a/react/src/components/BAILink.tsx b/react/src/components/BAILink.tsx index 6bfd558e82..21b663ba04 100644 --- a/react/src/components/BAILink.tsx +++ b/react/src/components/BAILink.tsx @@ -15,7 +15,7 @@ const useStyles = createStyles(({ css, token }) => ({ })); interface BAILinkProps extends LinkProps { - type: 'hover'; + type?: 'hover'; } const BAILink: React.FC<BAILinkProps> = ({ type, ...linkProps }) => { const { styles } = useStyles(); diff --git a/react/src/components/BAIPropertyFilter.tsx b/react/src/components/BAIPropertyFilter.tsx index 0b1e16a5aa..114c685119 100644 --- a/react/src/components/BAIPropertyFilter.tsx +++ b/react/src/components/BAIPropertyFilter.tsx @@ -91,7 +91,7 @@ function trimFilterValue(filterValue: string): string { } export function mergeFilterValues( - filterStrings: Array<string | undefined>, + filterStrings: Array<string | undefined | null>, operator: string = '&', ) { const mergedFilter = _.join( diff --git a/react/src/components/BAITable.tsx b/react/src/components/BAITable.tsx index 51fbd8e1ca..9ae27f71ff 100644 --- a/react/src/components/BAITable.tsx +++ b/react/src/components/BAITable.tsx @@ -1,8 +1,9 @@ import { useDebounce } from 'ahooks'; -import { GetProps, Table } from 'antd'; +import { ConfigProvider, GetProps, Table } from 'antd'; import { createStyles } from 'antd-style'; import { ColumnsType, ColumnType } from 'antd/es/table'; import { TableProps } from 'antd/lib'; +import { ComponentToken } from 'antd/lib/table/style'; import _ from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Resizable, ResizeCallbackData } from 'react-resizable'; @@ -23,6 +24,11 @@ const useStyles = createStyles(({ token, css }) => ({ whitespace: 'pre'; wordwrap: 'break-word'; } + + thead.ant-table-thead > tr > th.ant-table-cell { + font-weight: 500; + color: ${token.colorTextTertiary}; + } `, })); @@ -94,6 +100,7 @@ const ResizableTitle = ( interface BAITableProps<RecordType extends object = any> extends TableProps<RecordType> { resizable?: boolean; + tableComponentToken?: ComponentToken; } const columnKeyOrIndexKey = (column: any, index: number) => @@ -110,6 +117,7 @@ const BAITable = <RecordType extends object = any>({ resizable = false, columns, components, + tableComponentToken, ...tableProps }: BAITableProps<RecordType>) => { const { styles } = useStyles(); @@ -145,22 +153,35 @@ const BAITable = <RecordType extends object = any>({ }, [resizable, columns, resizedColumnWidths]); return ( - <Table - sortDirections={['descend', 'ascend', 'descend']} - showSorterTooltip={false} - className={resizable ? styles.resizableTable : ''} - components={ - resizable - ? _.merge(components || {}, { - header: { - cell: ResizableTitle, + <ConfigProvider + theme={ + tableComponentToken + ? { + components: { + Table: tableComponentToken, }, - }) - : components + } + : undefined } - columns={mergedColumns} - {...tableProps} - /> + > + <Table + size="small" + sortDirections={['descend', 'ascend', 'descend']} + showSorterTooltip={false} + className={resizable ? styles.resizableTable : ''} + components={ + resizable + ? _.merge(components || {}, { + header: { + cell: ResizableTitle, + }, + }) + : components + } + columns={mergedColumns} + {...tableProps} + /> + </ConfigProvider> ); }; diff --git a/react/src/components/ComputeSessionList.tsx b/react/src/components/ComputeSessionList.tsx deleted file mode 100644 index a1a90a1ecc..0000000000 --- a/react/src/components/ComputeSessionList.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import SessionDetailAndContainerLogOpenerLegacy from './SessionDetailAndContainerLogOpenerLegacy'; - -const ComputeSessionList = () => { - return ( - <> - <SessionDetailAndContainerLogOpenerLegacy /> - </> - ); -}; - -export default ComputeSessionList; diff --git a/react/src/components/ComputeSessionNodeItems/SessionOccupiedSlot.tsx b/react/src/components/ComputeSessionNodeItems/SessionOccupiedSlot.tsx new file mode 100644 index 0000000000..c10d39883e --- /dev/null +++ b/react/src/components/ComputeSessionNodeItems/SessionOccupiedSlot.tsx @@ -0,0 +1,54 @@ +import { convertBinarySizeUnit } from '../../helper'; +import { useResourceSlotsDetails } from '../../hooks/backendai'; +import { SessionOccupiedSlotFragment$key } from './__generated__/SessionOccupiedSlotFragment.graphql'; +import { Divider, Typography } from 'antd'; +import graphql from 'babel-plugin-relay/macro'; +import _ from 'lodash'; +import React from 'react'; +import { useFragment } from 'react-relay'; + +interface OccupiedSlotViewProps { + sessionFrgmt: SessionOccupiedSlotFragment$key; + type: 'cpu' | 'mem' | 'accelerator'; +} +const SessionOccupiedSlot: React.FC<OccupiedSlotViewProps> = ({ + type, + sessionFrgmt, +}) => { + const { deviceMetadata } = useResourceSlotsDetails(); + const session = useFragment( + graphql` + fragment SessionOccupiedSlotFragment on ComputeSessionNode { + id + occupied_slots + } + `, + sessionFrgmt, + ); + + const occupiedSlots = JSON.parse(session.occupied_slots || '{}'); + + if (type === 'cpu') { + return occupiedSlots.cpu ?? '-'; + } else if (type === 'mem') { + const mem = occupiedSlots.mem ?? '-'; + return mem === '-' ? mem : convertBinarySizeUnit(mem, 'G')?.number + ' GiB'; + } else if (type === 'accelerator') { + const occupiedAccelerators = _.omit(occupiedSlots, ['cpu', 'mem']); + return _.isEmpty(occupiedAccelerators) + ? '-' + : _.map(occupiedAccelerators, (value, key) => { + return ( + <> + <Typography.Text>{value}</Typography.Text> + <Divider type="vertical" /> + <Typography.Text> + {deviceMetadata?.[key]?.human_readable_name} + </Typography.Text> + </> + ); + }); + } +}; + +export default SessionOccupiedSlot; diff --git a/react/src/components/ComputeSessionNodeItems/SessionReservation.tsx b/react/src/components/ComputeSessionNodeItems/SessionReservation.tsx index 9f0d9aed9f..a3bc0e9a37 100644 --- a/react/src/components/ComputeSessionNodeItems/SessionReservation.tsx +++ b/react/src/components/ComputeSessionNodeItems/SessionReservation.tsx @@ -1,3 +1,4 @@ +import { formatDurationAsDays } from '../../helper'; import { useSuspendedBackendaiClient } from '../../hooks'; import BAIIntervalView from '../BAIIntervalView'; import DoubleTag from '../DoubleTag'; @@ -10,8 +11,8 @@ import { useFragment } from 'react-relay'; const SessionReservation: React.FC<{ sessionFrgmt: SessionReservationFragment$key; -}> = ({ sessionFrgmt }) => { - const baiClient = useSuspendedBackendaiClient(); + mode?: 'simple-elapsed' | 'detail'; +}> = ({ sessionFrgmt, mode = 'detail' }) => { const { t } = useTranslation(); const session = useFragment( graphql` @@ -25,25 +26,27 @@ const SessionReservation: React.FC<{ ); return ( <> - {dayjs(session.created_at).format('lll')} + {mode !== 'simple-elapsed' && dayjs(session.created_at).format('lll')} <BAIIntervalView + key={session.id} callback={() => { return session?.created_at - ? baiClient.utils.elapsedTime( - session.created_at, - session?.terminated_at, - ) + ? formatDurationAsDays(session.created_at, session?.terminated_at) : '-'; }} delay={1000} - render={(intervalValue) => ( - <DoubleTag - values={[ - { label: t('session.ElapsedTime') }, - { label: intervalValue }, - ]} - /> - )} + render={(intervalValue) => + mode === 'simple-elapsed' ? ( + intervalValue + ) : ( + <DoubleTag + values={[ + { label: t('session.ElapsedTime') }, + { label: intervalValue }, + ]} + /> + ) + } /> </> ); diff --git a/react/src/components/ComputeSessionNodeItems/SessionStatusTag.tsx b/react/src/components/ComputeSessionNodeItems/SessionStatusTag.tsx index a7213ac526..f6b9cc85f7 100644 --- a/react/src/components/ComputeSessionNodeItems/SessionStatusTag.tsx +++ b/react/src/components/ComputeSessionNodeItems/SessionStatusTag.tsx @@ -12,6 +12,7 @@ import { useFragment } from 'react-relay'; interface SessionStatusTagProps { sessionFrgmt?: SessionStatusTagFragment$key | null; + showInfo?: boolean; } const statusTagColor = { //prepare @@ -51,6 +52,7 @@ const statusInfoTagColor = { }; const SessionStatusTag: React.FC<SessionStatusTagProps> = ({ sessionFrgmt, + showInfo, }) => { const session = useFragment( graphql` @@ -66,7 +68,7 @@ const SessionStatusTag: React.FC<SessionStatusTagProps> = ({ const { token } = theme.useToken(); return session ? ( - _.isEmpty(session.status_info) ? ( + _.isEmpty(session.status_info) || !showInfo ? ( <Tag color={ session.status ? _.get(statusTagColor, session.status) : undefined diff --git a/react/src/components/ComputeSessionNodeItems/TerminateSessionModal.tsx b/react/src/components/ComputeSessionNodeItems/TerminateSessionModal.tsx index c9cef41539..2c73f53d5a 100644 --- a/react/src/components/ComputeSessionNodeItems/TerminateSessionModal.tsx +++ b/react/src/components/ComputeSessionNodeItems/TerminateSessionModal.tsx @@ -200,6 +200,7 @@ const TerminateSessionModal: React.FC<TerminateSessionModalProps> = ({ `, sessionFrgmt, ); + const [isForce, setIsForce] = useState(false); const userRole = useCurrentUserRole(); diff --git a/react/src/components/SessionDetailContent.tsx b/react/src/components/SessionDetailContent.tsx index f2da9038ac..0da1a59108 100644 --- a/react/src/components/SessionDetailContent.tsx +++ b/react/src/components/SessionDetailContent.tsx @@ -169,7 +169,7 @@ const SessionDetailContent: React.FC<{ label={t('session.Status')} contentStyle={{ display: 'flex', gap: token.marginSM }} > - <SessionStatusTag sessionFrgmt={session} /> + <SessionStatusTag sessionFrgmt={session} showInfo /> {/* <Button type="text" icon={<TriangleAlertIcon />} /> */} </Descriptions.Item> <Descriptions.Item label={t('session.SessionType')}> diff --git a/react/src/components/SessionNodes.tsx b/react/src/components/SessionNodes.tsx new file mode 100644 index 0000000000..3aa58f0021 --- /dev/null +++ b/react/src/components/SessionNodes.tsx @@ -0,0 +1,134 @@ +import BAILink from './BAILink'; +import BAITable from './BAITable'; +import SessionOccupiedSlot from './ComputeSessionNodeItems/SessionOccupiedSlot'; +import SessionReservation from './ComputeSessionNodeItems/SessionReservation'; +import SessionStatusTag from './ComputeSessionNodeItems/SessionStatusTag'; +import SessionDetailDrawer from './SessionDetailDrawer'; +import { SessionNodesFragment$key } from './__generated__/SessionNodesFragment.graphql'; +import { TableProps } from 'antd/lib'; +import graphql from 'babel-plugin-relay/macro'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useFragment } from 'react-relay'; + +interface SessionNodesProps extends Omit<TableProps, 'dataSource' | 'columns'> { + sessionsFrgmt: SessionNodesFragment$key; +} +const SessionNodes: React.FC<SessionNodesProps> = ({ + sessionsFrgmt, + ...tableProps +}) => { + const { t } = useTranslation(); + const [selectedSessionId, setSelectedSessionId] = useState<string>(); + + const sessions = useFragment( + graphql` + fragment SessionNodesFragment on ComputeSessionNode @relay(plural: true) { + id + row_id + name + ...SessionStatusTagFragment + ...SessionReservationFragment + ...SessionOccupiedSlotFragment + } + `, + sessionsFrgmt, + ); + + return ( + <> + <BAITable + resizable + rowKey={(record) => record.rowId} + dataSource={sessions} + scroll={{ x: 'max-content' }} + columns={[ + { + key: 'name', + title: t('session.SessionName'), + dataIndex: 'name', + render: (name: string, session) => { + return ( + <BAILink + to={'#'} + type="hover" + onClick={(e) => { + session.row_id && setSelectedSessionId(session.row_id); + }} + > + {name} + </BAILink> + ); + }, + sorter: true, + }, + { + key: 'status', + title: t('session.Status'), + dataIndex: 'status', + render: (status: string, session) => { + // @ts-expect-error + return <SessionStatusTag sessionFrgmt={session} />; + }, + }, + { + key: 'utils', + title: t('session.Utilization'), + }, + { + key: 'accelerator', + title: t('session.launcher.AIAccelerator'), + render: (__, session) => { + return ( + <SessionOccupiedSlot + // @ts-expect-error + sessionFrgmt={session} + type="accelerator" + /> + ); + }, + }, + { + key: 'cpu', + title: t('session.launcher.CPU'), + render: (__, session) => { + // @ts-expect-error + return <SessionOccupiedSlot sessionFrgmt={session} type="cpu" />; + }, + }, + { + key: 'mem', + title: t('session.launcher.Memory'), + render: (__, session) => { + // @ts-expect-error + return <SessionOccupiedSlot sessionFrgmt={session} type="mem" />; + }, + }, + { + key: 'elapsedTime', + title: t('session.ElapsedTime'), + render: (__, session) => { + return ( + <SessionReservation + mode="simple-elapsed" + // @ts-expect-error + sessionFrgmt={session} + /> + ); + }, + }, + ]} + {...tableProps} + /> + <SessionDetailDrawer + open={!selectedSessionId} + sessionId={selectedSessionId} + onClose={() => { + setSelectedSessionId(undefined); + }} + /> + </> + ); +}; + +export default SessionNodes; diff --git a/react/src/helper/index.tsx b/react/src/helper/index.tsx index e6062ac847..57de0bcf88 100644 --- a/react/src/helper/index.tsx +++ b/react/src/helper/index.tsx @@ -3,6 +3,7 @@ import { Image } from '../components/ImageEnvironmentSelectFormItems'; import { EnvironmentImage } from '../components/ImageList'; import { useSuspendedBackendaiClient } from '../hooks'; import { SorterResult } from 'antd/es/table/interface'; +import dayjs from 'dayjs'; import { Duration } from 'dayjs/plugin/duration'; import { TFunction } from 'i18next'; import _ from 'lodash'; @@ -452,3 +453,21 @@ export function formatDuration(duration: Duration, t: TFunction) { .filter(Boolean) .join(' '); } + +export function formatDurationAsDays( + start: string, + end?: string | null, + dayLabel: string = 'd ', +): string { + const startTime = dayjs(start); + const endTime = end ? dayjs(end) : dayjs(); + + const diff = dayjs.duration(endTime.diff(startTime)); + + const asDays = Math.floor(diff.asDays()); + const hours = diff.hours().toString().padStart(2, '0'); + const minutes = diff.minutes().toString().padStart(2, '0'); + const seconds = diff.seconds().toString().padStart(2, '0'); + + return `${asDays ? `${asDays}${dayLabel}` : ''}${hours}:${minutes}:${seconds}`; +} diff --git a/react/src/hooks/reactPaginationQueryOptions.tsx b/react/src/hooks/reactPaginationQueryOptions.tsx index 0359904054..f1f0e3b0e0 100644 --- a/react/src/hooks/reactPaginationQueryOptions.tsx +++ b/react/src/hooks/reactPaginationQueryOptions.tsx @@ -281,6 +281,7 @@ export const useBAIPaginationQueryOptions = ({ interface BAIPaginationOption { limit: number; offset: number; + first?: number; // filter?: string; // order?: string; } @@ -297,13 +298,16 @@ export const useBAIPaginationOptionState = ( ): { baiPaginationOption: BAIPaginationOption; tablePaginationOption: AntdBasicPaginationOption; - setTablePaginationOption: (pagination: AntdBasicPaginationOption) => void; + setTablePaginationOption: ( + pagination: Partial<AntdBasicPaginationOption>, + ) => void; } => { const [options, setOptions] = useState<AntdBasicPaginationOption>(initialOptions); return { baiPaginationOption: { limit: options.pageSize, + first: options.pageSize, offset: options.current > 1 ? (options.current - 1) * options.pageSize : 0, }, diff --git a/react/src/pages/ComputeSessionListPage.tsx b/react/src/pages/ComputeSessionListPage.tsx new file mode 100644 index 0000000000..bdbce1653e --- /dev/null +++ b/react/src/pages/ComputeSessionListPage.tsx @@ -0,0 +1,250 @@ +import BAILink from '../components/BAILink'; +import BAIPropertyFilter, { + mergeFilterValues, +} from '../components/BAIPropertyFilter'; +import Flex from '../components/Flex'; +import SessionNodes from '../components/SessionNodes'; +import { filterNonNullItems, transformSorterToOrderString } from '../helper'; +import { useBAIPaginationOptionState } from '../hooks/reactPaginationQueryOptions'; +import { useCurrentProjectValue } from '../hooks/useCurrentProject'; +import { ComputeSessionListPageQuery } from './__generated__/ComputeSessionListPageQuery.graphql'; +import { LoadingOutlined } from '@ant-design/icons'; +import { Badge, Button, Card, Radio, Tabs, theme } from 'antd'; +import graphql from 'babel-plugin-relay/macro'; +import _ from 'lodash'; +import { useState, useTransition } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLazyLoadQuery } from 'react-relay'; +import { + JsonParam, + StringParam, + useQueryParams, + withDefault, +} from 'use-query-params'; + +type TypeFilterType = 'all' | 'interactive' | 'batch' | 'inference' | 'system'; +const ComputeSessionList = () => { + const currentProject = useCurrentProjectValue(); + + const { t } = useTranslation(); + const { token } = theme.useToken(); + + const { + baiPaginationOption, + tablePaginationOption, + setTablePaginationOption, + } = useBAIPaginationOptionState({ + current: 1, + pageSize: 10, + }); + const [isPendingPageChange, startPageChangeTransition] = useTransition(); + const [isPendingFilterChange, startFilterChangeTransition] = useTransition(); + + const [query, setQuery] = useQueryParams({ + order: StringParam, + filterString: StringParam, + typeFilterType: withDefault(StringParam, 'all'), + runningFilterType: withDefault(StringParam, 'running'), + }); + + const { order, filterString, typeFilterType, runningFilterType } = query; + + const typeFilter = + typeFilterType === 'all' ? undefined : `type == "${typeFilterType}"`; + + const statusFilter = + runningFilterType === 'running' + ? 'status != "TERMINATED" & status != "CANCELLED"' + : 'status == "TERMINATED" | status == "CANCELLED"'; + + const { compute_session_nodes, allRunningSessionForCount } = + useLazyLoadQuery<ComputeSessionListPageQuery>( + graphql` + query ComputeSessionListPageQuery( + $projectId: UUID! + $first: Int = 20 + $offset: Int = 0 + $filter: String + $order: String + $runningTypeFilter: String! + ) { + compute_session_nodes( + project_id: $projectId + first: $first + offset: $offset + filter: $filter + order: $order + ) { + edges @required(action: THROW) { + node @required(action: THROW) { + id + ...SessionNodesFragment + } + } + count + } + allRunningSessionForCount: compute_session_nodes( + project_id: $projectId + first: 0 + offset: 0 + filter: $runningTypeFilter + ) { + count + } + } + `, + { + projectId: currentProject.id, + offset: baiPaginationOption.offset, + first: baiPaginationOption.first, + filter: mergeFilterValues([statusFilter, filterString, typeFilter]), + order, + runningTypeFilter: 'status != "TERMINATED" & status != "CANCELLED"', + }, + { + fetchPolicy: 'network-only', + }, + ); + + return ( + <> + {/* TODO: add legacy opener */} + {/* <SessionDetailAndContainerLogOpenerForLegacy /> */} + <Card + bordered={false} + title={t('webui.menu.Sessions')} + extra={[ + <BAILink to={'/session/start'}> + <Button type="primary">Start Session</Button> + </BAILink>, + ]} + styles={{ + header: { + borderBottom: 'none', + }, + body: { + paddingTop: 0, + }, + }} + > + <Tabs + type="card" + activeKey={typeFilterType} + onChange={(key) => { + startFilterChangeTransition(() => { + setQuery({ typeFilterType: key as TypeFilterType }); + setTablePaginationOption({ current: 1 }); + }); + }} + items={_.map( + { + all: t('general.All'), + interactive: t('session.Interactive'), + batch: t('session.Batch'), + inference: t('session.Inference'), + system: t('session.System'), + }, + (label, key) => ({ + key, + label: ( + <Flex justify="center" gap={10}> + {label} + {key === 'all' && ( + <Badge + count={allRunningSessionForCount?.count} + color={token.colorPrimary} + size="small" + showZero + style={{ + paddingRight: token.paddingXS, + paddingLeft: token.paddingXS, + fontSize: 10, + }} + /> + )} + {/* */} + </Flex> + ), + }), + )} + /> + <Flex direction="column" align="stretch" gap={'sm'}> + <Flex gap={'sm'} align="start"> + <Radio.Group + optionType="button" + value={runningFilterType} + onChange={(e) => { + startFilterChangeTransition(() => { + setQuery({ runningFilterType: e.target.value }); + }); + }} + options={[ + { + label: 'Running', + value: 'running', + }, + { + label: 'Finished', + value: 'finished', + }, + ]} + /> + <BAIPropertyFilter + filterProperties={[ + { + key: 'name', + propertyLabel: t('session.SessionName'), + type: 'string', + }, + ]} + value={filterString || undefined} + onChange={(value) => { + startFilterChangeTransition(() => { + setQuery({ filterString: value }); + setTablePaginationOption({ current: 1 }); + }); + }} + /> + </Flex> + <SessionNodes + rowSelection={{ + type: 'checkbox', + // onChange: (selectedRowKeys, selectedRows) => { + // console.log( + // `selectedRowKeys: ${selectedRowKeys}`, + // 'selectedRows: ', + // selectedRows, + // ); + // }, + }} + sessionsFrgmt={filterNonNullItems( + compute_session_nodes?.edges.map((e) => e?.node), + )} + pagination={{ + pageSize: tablePaginationOption.pageSize, + current: tablePaginationOption.current, + total: compute_session_nodes?.count ?? 0, + // showTotal: (total) => { + // return total; + // }, + }} + loading={{ + spinning: isPendingPageChange || isPendingFilterChange, + indicator: <LoadingOutlined />, + }} + onChange={({ current, pageSize }, filters, sorter) => { + startPageChangeTransition(() => { + if (_.isNumber(current) && _.isNumber(pageSize)) { + setTablePaginationOption({ current, pageSize }); + } + setQuery({ order: transformSorterToOrderString(sorter) }); + }); + }} + /> + </Flex> + </Card> + </> + ); +}; + +export default ComputeSessionList; diff --git a/resources/theme.json b/resources/theme.json index 1b2c28a8b9..d528988fad 100644 --- a/resources/theme.json +++ b/resources/theme.json @@ -14,7 +14,7 @@ "borderRadiusSM": 1 }, "Table": { - "headerBorderRadius": 0 + "headerBg": "#E3E3E3" }, "Layout": { "lightSiderBg": "#FFF",