diff --git a/README.md b/README.md index 8cf9d8e8..ef91e8c0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,42 @@ -# README +## Installation -gpustack ui +1. [Nodejs](https://nodejs.org/en) 16.0+(with NPM) + +If you're on Mac + +``` +brew install node +``` + +2. [pnpm](https://pnpm.io/installation#using-npm) + +``` +npm install -g pnpm +``` + +3. Setup + +``` +git clone https://github.com/seal-io/gpustack-ui/ +``` + +4. Install dependencies + +``` +cd gpustack-ui +pnpm install +``` + +## Usage + +1. Run development server (at http://localhost:9000) + +``` +npm run dev +``` + +2. build release + +``` +npm run build +``` diff --git a/src/app.tsx b/src/app.tsx index a7cb0690..084e7e43 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,12 +1,38 @@ -import { RequestConfig } from '@umijs/max'; +import { RequestConfig, history } from '@umijs/max'; import { requestConfig } from './request-config'; +import { queryCurrentUserState } from './services/profile/apis'; + +const loginPath = '/login'; // 运行时配置 // 全局初始化数据配置,用于 Layout 用户信息和权限初始化 // 更多信息见文档:https://umijs.org/docs/api/runtime-config#getinitialstate -export async function getInitialState(): Promise<{ name: string }> { - console.log('getInitialState+++++++++++++++'); +export async function getInitialState() { + // 如果不是登录页面,执行 + const { location } = history; + + const fetchUserInfo = async () => { + try { + const data = await queryCurrentUserState({ + skipErrorHandler: true + }); + return data; + } catch (error) { + history.push(loginPath); + } + return undefined; + }; + + if (![loginPath].includes(location.pathname)) { + const currentUser = await fetchUserInfo(); + return { + fetchUserInfo, + name: 'admin', + ...currentUser + }; + } return { + fetchUserInfo, name: 'admin' }; } diff --git a/src/components/seal-table/components/seal-column.tsx b/src/components/seal-table/components/seal-column.tsx new file mode 100644 index 00000000..1915877b --- /dev/null +++ b/src/components/seal-table/components/seal-column.tsx @@ -0,0 +1,27 @@ +import classNames from 'classnames'; +import React, { useContext } from 'react'; +import RowContext from '../row-context'; +import '../styles/cell.less'; +import { SealColumnProps } from '../types'; + +const SealColumn: React.FC = (props) => { + const row: Record = useContext(RowContext); + const { dataIndex, render, align } = props; + return ( +
+ + {render + ? render(row[dataIndex], { ...row, dataIndex }) + : row[dataIndex]} + +
+ ); +}; + +export default React.memo(SealColumn); diff --git a/src/components/seal-table/components/table-header.tsx b/src/components/seal-table/components/table-header.tsx new file mode 100644 index 00000000..61d18b3f --- /dev/null +++ b/src/components/seal-table/components/table-header.tsx @@ -0,0 +1,24 @@ +import classNames from 'classnames'; +import React from 'react'; +import '../styles/header.less'; +import { TableHeaderProps } from '../types'; + +const TableHeader: React.FC = (props) => { + const { title, style, align, firstCell, lastCell } = props; + return ( +
+
{title}
+
+ ); +}; + +export default React.memo(TableHeader); diff --git a/src/components/seal-table/components/table-row.tsx b/src/components/seal-table/components/table-row.tsx new file mode 100644 index 00000000..f5b540d7 --- /dev/null +++ b/src/components/seal-table/components/table-row.tsx @@ -0,0 +1,137 @@ +import { DownOutlined, RightOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Col, Row } from 'antd'; +import classNames from 'classnames'; +import _ from 'lodash'; +import React, { useEffect, useState } from 'react'; +import RowContext from '../row-context'; +import { RowContextProps, SealTableProps } from '../types'; + +const TableRow: React.FC< + RowContextProps & + Omit +> = (props) => { + const { + record, + rowIndex, + expandable, + rowSelection, + rowKey, + columns, + onExpand + } = props; + + const [expanded, setExpanded] = useState(false); + const [checked, setChecked] = useState(false); + + useEffect(() => { + if (rowSelection) { + const { selectedRowKeys } = rowSelection; + if (selectedRowKeys.includes(record[rowKey])) { + setChecked(true); + } else { + setChecked(false); + } + } + }, [rowSelection]); + + const handleRowExpand = () => { + setExpanded(!expanded); + onExpand?.(!expanded, record); + }; + + const handleSelectChange = (e: any) => { + if (e.target.checked) { + // update selectedRowKeys + rowSelection?.onChange( + _.uniq([...rowSelection?.selectedRowKeys, record[rowKey]]) + ); + } else { + // update selectedRowKeys + rowSelection?.onChange( + rowSelection?.selectedRowKeys.filter((key) => key !== record[rowKey]) + ); + } + }; + + const renderRowPrefix = () => { + if (expandable && rowSelection) { + return ( +
+ + {_.isBoolean(expandable) ? ( + + ) : ( + expandable + )} + + +
+ ); + } + if (expandable) { + return ( +
+ {_.isBoolean(expandable) ? ( + + ) : ( + expandable + )} +
+ ); + } + if (rowSelection) { + return ( +
+ { + + } +
+ ); + } + return null; + }; + + return ( + +
+
+ {renderRowPrefix()} + + {React.Children.map(columns, (child) => { + const { props: columnProps } = child as any; + if (React.isValidElement(child)) { + return ( + + {child} + + ); + } + return ; + })} + +
+ {expanded && ( +
+
rowchildren
+
+ )} +
+
+ ); +}; + +export default TableRow; diff --git a/src/components/seal-table/index.tsx b/src/components/seal-table/index.tsx new file mode 100644 index 00000000..63e67e59 --- /dev/null +++ b/src/components/seal-table/index.tsx @@ -0,0 +1,145 @@ +import { RightOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Col, Empty, Row, Spin } from 'antd'; +import _ from 'lodash'; +import React, { useEffect, useState } from 'react'; +import TableHeader from './components/table-header'; +import TableRow from './components/table-row'; +import './styles/index.less'; +import { SealColumnProps, SealTableProps } from './types'; + +const SealTable: React.FC = (props) => { + const { children, rowKey, onExpand, loading, expandable, rowSelection } = + props; + + const [selectAll, setSelectAll] = useState(false); + const [indeterminate, setIndeterminate] = useState(false); + + useEffect(() => { + if (rowSelection) { + const { selectedRowKeys } = rowSelection; + if (selectedRowKeys.length === 0) { + setSelectAll(false); + setIndeterminate(false); + } else if (selectedRowKeys.length === props.dataSource.length) { + setSelectAll(true); + setIndeterminate(false); + } else { + setSelectAll(false); + setIndeterminate(true); + } + } + }, [rowSelection]); + + const handleSelectAllChange = (e: any) => { + if (e.target.checked) { + // update selectedRowKeys + rowSelection?.onChange(props.dataSource.map((record) => record[rowKey])); + setSelectAll(true); + setIndeterminate(false); + } else { + // update selectedRowKeys + rowSelection?.onChange([]); + setSelectAll(false); + setIndeterminate(false); + } + }; + const renderHeaderPrefix = () => { + if (expandable && rowSelection) { + return ( +
+ + +
+ ); + } + if (expandable) { + return ( +
+ {_.isBoolean(expandable) ? ( + + ) : ( + expandable + )} +
+ ); + } + if (rowSelection) { + return ( +
{}
+ ); + } + return null; + }; + + const renderHeader = () => { + return ( +
+ {renderHeaderPrefix()} + + {React.Children.map(props.children, (child, i) => { + const { props: columnProps } = child as any; + const { title, align, span, headerStyle } = + columnProps as SealColumnProps; + if (React.isValidElement(child)) { + return ( + + + + ); + } + return null; + })} + +
+ ); + }; + + const renderContent = () => { + if (!props.dataSource.length) { + return ; + } + return ( +
+ {props.dataSource.map((item, index) => { + return ( + + ); + })} +
+ ); + }; + return ( +
+ {renderHeader()} + {loading ? ( +
+ +
+ ) : ( + renderContent() + )} +
+ ); +}; + +export default SealTable; diff --git a/src/components/seal-table/row-context.ts b/src/components/seal-table/row-context.ts new file mode 100644 index 00000000..96d2e7d2 --- /dev/null +++ b/src/components/seal-table/row-context.ts @@ -0,0 +1,5 @@ +import React from 'react'; + +const RowContext = React.createContext({}); + +export default RowContext; diff --git a/src/components/seal-table/styles/cell.less b/src/components/seal-table/styles/cell.less new file mode 100644 index 00000000..152868d8 --- /dev/null +++ b/src/components/seal-table/styles/cell.less @@ -0,0 +1,17 @@ +.cell { + padding: var(--ant-table-cell-padding-block) + var(--ant-table-cell-padding-inline); + display: flex; + align-items: center; + justify-content: flex-start; + height: 70px; + &-left { + justify-content: flex-start; + } + &-right { + justify-content: flex-end; + } + &-center { + justify-content: center; + } +} diff --git a/src/components/seal-table/styles/header.less b/src/components/seal-table/styles/header.less new file mode 100644 index 00000000..bce01913 --- /dev/null +++ b/src/components/seal-table/styles/header.less @@ -0,0 +1,23 @@ +.table-header { + display: flex; + align-items: center; + justify-content: flex-start; + padding-inline: var(--ant-table-cell-padding-inline); + border-right: 1px solid var(--ant-table-header-split-color); + + &-last { + border-right: none; + } + &-cell { + font-weight: 600; + } + &-left { + justify-content: flex-start; + } + &-right { + justify-content: flex-end; + } + &-center { + justify-content: center; + } +} diff --git a/src/components/seal-table/styles/index.less b/src/components/seal-table/styles/index.less new file mode 100644 index 00000000..c80ecef7 --- /dev/null +++ b/src/components/seal-table/styles/index.less @@ -0,0 +1,64 @@ +.seal-table-container { + display: flex; + flex-direction: column; + width: 100%; + .header-row-wrapper { + height: 40px; + margin-bottom: 10px; + display: flex; + justify-content: flex-start; + align-items: center; + .header-row-prefix-wrapper { + padding-left: var(--ant-table-cell-padding-inline); + } + .row { + flex: 1; + } + } + .row-box { + margin-bottom: 20px; + border-radius: var(--ant-table-header-border-radius); + overflow: hidden; + } + .expanded-row { + background-color: var(--color-white-1); + padding: 16px 20px; + border: 1px solid var(--color-fill-1); + border-top: 0; + border-radius: 0 0 var(--ant-table-header-border-radius) + var(--ant-table-header-border-radius); + } + .row-wrapper { + display: flex; + justify-content: flex-start; + align-items: center; + background-color: var(--color-fill-1); + transition: all 0.2s ease; + &:hover { + background-color: var(--ant-table-row-hover-bg); + transition: all 0.2s ease; + } + &-selected { + background-color: var(--ant-table-row-selected-bg); + transition: all 0.2s ease; + &:hover { + background-color: var(--ant-table-row-selected-hover-bg); + transition: all 0.2s ease; + } + } + .row-prefix-wrapper { + padding-left: var(--ant-table-cell-padding-inline); + } + } + .seal-table-row { + flex: 1; + } + .spin { + text-align: center; + display: flex; + justify-content: center; + align-items: center; + margin-top: 20px; + min-height: 100px; + } +} diff --git a/src/components/seal-table/types.ts b/src/components/seal-table/types.ts new file mode 100644 index 00000000..e544a766 --- /dev/null +++ b/src/components/seal-table/types.ts @@ -0,0 +1,41 @@ +import React from 'react'; + +export interface SealColumnProps { + title: string; + render?: (text: any, record: any) => React.ReactNode; + dataIndex: string; + width?: number; + span: number; + align?: 'left' | 'center' | 'right'; + headerStyle?: React.CSSProperties; +} + +export interface TableHeaderProps { + children?: React.ReactNode; + title: string; + style?: React.CSSProperties; + firstCell?: boolean; + lastCell?: boolean; + align?: 'left' | 'center' | 'right'; +} + +export interface RowSelectionProps { + selectedRowKeys: React.Key[]; + onChange: (selectedRowKeys: React.Key[]) => void; +} +export interface SealTableProps { + rowSelection?: RowSelectionProps; + children: React.ReactNode[]; + empty?: React.ReactNode; + expandable?: React.ReactNode; + dataSource: any[]; + loading?: boolean; + onExpand?: (expanded: boolean, record: any) => void; + rowKey: string; +} + +export interface RowContextProps { + record: Record; + columns: React.ReactNode[]; + rowIndex: number; +} diff --git a/src/global.less b/src/global.less index 01dfc085..2fded4d3 100644 --- a/src/global.less +++ b/src/global.less @@ -15,6 +15,13 @@ html { --ant-color-fill-secondary: rgba(0, 0, 0, 0.06); --table-td-radius: 24px; --checkbox-border-radius: 4px; + --ant-table-cell-padding-inline: 16px; + --ant-table-cell-padding-block: 16px; + --ant-table-header-border-radius: 16px; + --ant-table-header-split-color: #f0f0f0; + --ant-table-row-selected-bg: #f0fff6; + --ant-table-row-selected-hover-bg: #d8f2e4; + --ant-table-row-hover-bg: #e6e6e6; // --color-text-1: #000; --seal-transition-func: cubic-bezier(0, 0, 1, 1); diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 500a4442..6477e7cc 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -86,6 +86,7 @@ export default (props: any) => { loading: false, setInitialState: null }; + console.log('initialInfo==========', initialInfo); const { initialState, loading, setInitialState } = initialInfo; const userConfig = { diff --git a/src/pages/Resources/components/test.tsx b/src/pages/Resources/components/test.tsx new file mode 100644 index 00000000..cca4b650 --- /dev/null +++ b/src/pages/Resources/components/test.tsx @@ -0,0 +1,22 @@ +import SealTable from '@/components/seal-table'; +import SealColumn from '@/components/seal-table/components/seal-column'; +import useTableRowSelection from '@/hooks/use-table-row-selection'; + +const Table = () => { + const dataSource = [{ name: 'test' }, { name: 'test2' }]; + const rowSelection = useTableRowSelection(); + return ( + + + + + ); +}; + +export default Table; diff --git a/src/pages/Resources/index.tsx b/src/pages/Resources/index.tsx index e5418e1d..8f2c19cf 100644 --- a/src/pages/Resources/index.tsx +++ b/src/pages/Resources/index.tsx @@ -4,8 +4,14 @@ import { Tabs } from 'antd'; import { useState } from 'react'; import GPUs from './components/gpus'; import Nodes from './components/nodes'; +import Test from './components/test'; const items: TabsProps['items'] = [ + { + key: 'test', + label: 'Test', + children: + }, { key: 'nodes', label: 'Nodes', @@ -18,7 +24,7 @@ const items: TabsProps['items'] = [ } ]; const Resources = () => { - const [activeKey, setActiveKey] = useState('nodes'); + const [activeKey, setActiveKey] = useState('test'); const handleChangeTab = (key: string) => { setActiveKey(key); diff --git a/src/pages/llmodels/index.tsx b/src/pages/llmodels/index.tsx index fd8cc3f8..9d595339 100644 --- a/src/pages/llmodels/index.tsx +++ b/src/pages/llmodels/index.tsx @@ -1,4 +1,6 @@ import PageTools from '@/components/page-tools'; +import SealTable from '@/components/seal-table'; +import SealColumn from '@/components/seal-table/components/seal-column'; import { PageAction } from '@/config'; import type { PageActionType } from '@/config/types'; import useTableRowSelection from '@/hooks/use-table-row-selection'; @@ -208,11 +210,12 @@ const Models: React.FC = () => { } > - { onChange: handlePageChange }} > - { return ( <> @@ -243,7 +247,8 @@ const Models: React.FC = () => { ); }} /> - { return dayjs(val).format('YYYY-MM-DD HH:mm:ss'); }} /> - { @@ -282,7 +288,7 @@ const Models: React.FC = () => { ) : null; }} /> -
+ { { + render={(text, record: ListItem) => { return ( diff --git a/src/request-config.ts b/src/request-config.ts index 4118d756..c5556f0c 100644 --- a/src/request-config.ts +++ b/src/request-config.ts @@ -7,6 +7,7 @@ export const requestConfig: RequestConfig = { console.log('errorThrower+++++++++++++++', res); }, errorHandler: (error: any, opts: any) => { + if (opts?.skipErrorHandler) throw error; const { message: errorMessage, response } = error; const errMsg = response?.data?.message || errorMessage; message.error(errMsg); diff --git a/src/services/profile/apis.ts b/src/services/profile/apis.ts new file mode 100644 index 00000000..1e96bf4f --- /dev/null +++ b/src/services/profile/apis.ts @@ -0,0 +1,8 @@ +import { request } from '@umijs/max'; + +export async function queryCurrentUserState(opts?: Record) { + return request(`/users/me`, { + method: 'GET', + ...opts + }); +}