chore: create cluster ux

This commit is contained in:
jialin
2025-09-16 11:26:17 +08:00
parent 55b0a20a4a
commit 62dbd18ef8
26 changed files with 690 additions and 392 deletions
-10
View File
@@ -54,16 +54,6 @@ export default [
defaultIcon: 'icon-reranker',
component: './playground/rerank'
},
// {
// name: 'text2images',
// title: 'Text2Images',
// path: keepAliveRoutes.text2images,
// key: 'text2images',
// icon: 'icon-image1',
// selectedIcon: 'icon-image-filled',
// defaultIcon: 'icon-image1',
// component: './playground/images'
// },
{
name: 'speech',
title: 'Speech',
+4
View File
@@ -324,3 +324,7 @@ textarea:hover {
.ant-result-extra {
color: var(--ant-color-text-secondary);
}
.text-success {
color: var(--ant-color-success);
}
+6 -4
View File
@@ -24,6 +24,8 @@ export default function useChartConfig() {
splitLineColor: token.colorBorder,
tickLineColor: token.colorSplit,
axislabelColor: token.colorTextTertiary,
colorSecondary: token.colorTextSecondary,
colorTertiary: token.colorTextTertiary,
gaugeBgColor: token.colorFillSecondary,
gaugeSplitLineColor: isDarkTheme
? 'rgba(255,255,255,.3)'
@@ -172,8 +174,8 @@ export default function useChartConfig() {
}
},
axisTick: {
distance: -11,
length: 6,
distance: -10,
length: 5,
splitNumber: 5,
lineStyle: {
width: 1.5,
@@ -206,13 +208,13 @@ export default function useChartConfig() {
rich: {
value: {
fontSize: 16,
fontWeight: '500',
fontWeight: '600',
color: chartColorMap.titleColor
},
unit: {
fontSize: 14,
color: chartColorMap.titleColor,
fontWeight: '500',
fontWeight: '600',
padding: [0, 0, 0, 2]
}
}
+12 -3
View File
@@ -1,7 +1,7 @@
import Chart from '@/components/echarts/chart';
import useChartConfig from '@/components/echarts/config';
import EmptyData from '@/components/empty-data';
import React, { memo } from 'react';
import React from 'react';
import { ChartProps } from './types';
const strokeColorFunc = (percent: number) => {
@@ -34,10 +34,19 @@ const GaugeChart: React.FC<Omit<ChartProps, 'seriesData' | 'xAxisData'>> = (
...gaugeItemConfig,
...gaugeConfig
};
combineGaugeConfig.detail.rich.value.color = colorValue;
combineGaugeConfig.detail.rich.unit.color = colorValue;
return {
title: {
...titleConfig,
text: title,
textStyle: {
fontSize: 12,
color: chartColorMap.colorSecondary,
fontWeight: 400
},
top: '0',
left: 'center'
},
@@ -58,7 +67,7 @@ const GaugeChart: React.FC<Omit<ChartProps, 'seriesData' | 'xAxisData'>> = (
color: 'transparent'
},
detail: {
...gaugeItemConfig.detail,
...combineGaugeConfig.detail,
formatter: labelFormatter || gaugeItemConfig.detail.formatter
},
data: [{ value }]
@@ -78,4 +87,4 @@ const GaugeChart: React.FC<Omit<ChartProps, 'seriesData' | 'xAxisData'>> = (
);
};
export default memo(GaugeChart);
export default GaugeChart;
+37 -5
View File
@@ -1,10 +1,20 @@
import OverlayScroller from '@/components/overlay-scroller';
import useBodyScroll from '@/hooks/use-body-scroll';
import useOverlayScroller from '@/hooks/use-overlay-scroller';
import { Modal, type ModalProps } from 'antd';
import React from 'react';
import styled from 'styled-components';
const Wrapper = styled.div<{ $maxHeight?: number }>`
max-height: ${({ $maxHeight }) =>
typeof $maxHeight === 'number' ? `${$maxHeight}px` : $maxHeight};
overflow-y: auto;
width: 100%;
`;
const ScrollerModal = (props: ModalProps & { maxContentHeight?: number }) => {
const scroller = React.useRef<any>(null);
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const { initialize, destroyInstance } = useOverlayScroller();
React.useEffect(() => {
if (props.open) {
@@ -14,6 +24,24 @@ const ScrollerModal = (props: ModalProps & { maxContentHeight?: number }) => {
}
}, [props.open]);
// init scroller, delay to ensure modal is fully open
React.useEffect(() => {
let timeout = null;
if (props.open) {
timeout = setTimeout(() => {
if (scroller.current) {
initialize(scroller.current);
}
}, 100);
}
return () => {
if (timeout) {
clearTimeout(timeout);
}
destroyInstance();
};
}, [props.open, initialize]);
return (
<Modal
{...props}
@@ -34,12 +62,16 @@ const ScrollerModal = (props: ModalProps & { maxContentHeight?: number }) => {
}
}}
>
<OverlayScroller
style={{ paddingInline: 24, paddingBlockEnd: props.footer ? 0 : 32 }}
maxHeight={props.maxContentHeight || 500}
<Wrapper
ref={scroller}
data-overlayscrollbars-initialize
className="overlay-scroller-wrapper"
$maxHeight={props.maxContentHeight || 500}
hidden={false}
style={{ paddingInline: 24, paddingBlockEnd: props.footer ? 0 : 24 }}
>
{props.children}
</OverlayScroller>
</Wrapper>
</Modal>
);
};
+14 -2
View File
@@ -1,6 +1,18 @@
import useUserSettings from '@/hooks/use-user-settings';
import { Tag, TagProps } from 'antd';
import React from 'react';
import styled from 'styled-components';
const TagWrapper = styled(Tag)`
display: flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
height: 22px;
opacity: 0.7;
`;
const ThemeTag: React.FC<TagProps & { opacity?: number }> = ({
opacity,
@@ -11,7 +23,7 @@ const ThemeTag: React.FC<TagProps & { opacity?: number }> = ({
const { userSettings } = useUserSettings();
const { isDarkTheme } = userSettings;
return (
<Tag
<TagWrapper
{...restProps}
style={{
...style,
@@ -19,7 +31,7 @@ const ThemeTag: React.FC<TagProps & { opacity?: number }> = ({
}}
>
{children}
</Tag>
</TagWrapper>
);
};
+13 -2
View File
@@ -8,13 +8,14 @@ interface CardProps {
children?: React.ReactNode;
clickable?: boolean;
ghost?: boolean;
header?: React.ReactNode;
footer?: React.ReactNode;
onClick?: () => void;
}
const CardWrapper = styled.div`
overflow: hidden;
display: flex;
padding: 16px 20px;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
@@ -39,6 +40,12 @@ const CardWrapper = styled.div`
}
`;
const CardContent = styled.div`
padding: 16px 20px;
width: 100%;
flex: 1;
`;
const Card: React.FC<CardProps> = (props) => {
const {
className,
@@ -46,6 +53,8 @@ const Card: React.FC<CardProps> = (props) => {
children,
clickable = true,
ghost = false,
header,
footer,
onClick
} = props;
@@ -58,7 +67,9 @@ const Card: React.FC<CardProps> = (props) => {
style={{ height: height || '180px' }}
onClick={onClick}
>
{children}
{header}
<CardContent>{children}</CardContent>
{footer}
</CardWrapper>
);
};
+5 -1
View File
@@ -55,5 +55,9 @@ export default function useBodyScroll() {
};
}, []);
return { saveScrollHeight, restoreScrollHeight };
return {
saveScrollHeight,
restoreScrollHeight,
bodyScroller: bodyScroller.current
};
}
+7
View File
@@ -199,6 +199,12 @@ export default function useOverlayScroller(data?: {
[initialize, instance]
);
const destroyInstance = () => {
instanceRef.current?.destroy?.();
removeWheelEvent();
instanceRef.current = null;
};
useEffect(() => {
return () => {
instanceRef.current?.destroy?.();
@@ -212,6 +218,7 @@ export default function useOverlayScroller(data?: {
scrollEventElement: scrollEventElement.current,
initialized: initialized.current,
generateInstance,
destroyInstance: destroyInstance,
updateScrollerPosition: throttledUpdateScrollerPosition,
updateScrollerPositionToTop: updateScrollerPositionToTop
};
+1 -2
View File
@@ -44,8 +44,7 @@ export default {
'resources.worker.add.step1':
'Get Token <span class="note-text">(Run on the server)</span>',
'resources.worker.add.step2': 'Register Worker',
'resources.worker.add.step2.tips':
'(Run on the worker to be added, <span class="bold-text">token</span> is the value obtained in the first step.)',
'resources.worker.add.step2.tips': '(Run on the worker to be added.)',
'resources.worker.add.step3':
'After success, refresh the workers list to view the new worker.',
'resources.worker.container.supported': 'Do not support macOS or Windows.',
+1 -2
View File
@@ -44,8 +44,7 @@ export default {
'resources.worker.add.step1':
'トークンを取得 <span class="note-text">(サーバーで実行)</span>',
'resources.worker.add.step2': 'ワーカーを登録',
'resources.worker.add.step2.tips':
'(追加するワーカーで実行し、<span class="bold-text">トークン</span> は最初のステップで取得した値です。)',
'resources.worker.add.step2.tips': '(追加するワーカーで実行し。)',
'resources.worker.add.step3':
'成功後、ワーカーリストを更新して新しいワーカーを確認してください。',
'resources.worker.container.supported':
+1 -2
View File
@@ -44,8 +44,7 @@ export default {
'resources.worker.add.step1':
'Получить токен <span class="note-text">(Запустить на сервере)</span>',
'resources.worker.add.step2': 'Зарегистрировать воркер',
'resources.worker.add.step2.tips':
'(Запустить на добавляемом воркере, <span class="bold-text">token</span> — это значение, полученное на первом шаге.)', // Translated
'resources.worker.add.step2.tips': '(Запустить на добавляемом воркере.)', // Translated
'resources.worker.add.step3':
'После успешной регистрации обновите список воркеров.',
'resources.worker.container.supported': 'Только для Linux.',
+1 -2
View File
@@ -44,8 +44,7 @@ export default {
'resources.worker.add.step1':
'获取 Token<span class="note-text">(在 Server 上运行)</span>',
'resources.worker.add.step2': '注册 Worker',
'resources.worker.add.step2.tips':
'(在需要添加的 Worker 上运行,<span class="bold-text">token</span> 为第一步获取到的值。)',
'resources.worker.add.step2.tips': '(在需要添加的 Worker 上运行。)',
'resources.worker.add.step3': '成功后,刷新 Worker 列表即可看到新的 Worker',
'resources.worker.container.supported': '不支持 macOS 和 Windows',
'resources.worker.current.version': '当前版本为 {version}',
+15 -28
View File
@@ -25,7 +25,6 @@ import {
updateCredential
} from './apis';
import AddCluster from './components/add-cluster';
import AddPool from './components/add-pool';
import ClusterItem from './components/cluster-item';
import { ClusterDataList } from './config';
import {
@@ -146,7 +145,10 @@ const Credentials: React.FC = () => {
});
const intl = useIntl();
const [open, setOpen] = useState(false);
const [openAddWorker, setOpenAddWorker] = useState({
open: false,
token: ''
});
const [openAddModal, setOpenAddModal] = useState(false);
const [provider, setProvider] = useState<string>('custom');
const [action, setAction] = useState<PageActionType>(PageAction.CREATE);
@@ -222,7 +224,7 @@ const Credentials: React.FC = () => {
setOpenAddModal(false);
};
const handleEditUser = (row: ListItem) => {
const handleEditCluster = (row: ListItem) => {
setCurrentData(row);
setOpenAddModal(true);
setAction(PageAction.EDIT);
@@ -231,11 +233,14 @@ const Credentials: React.FC = () => {
const handleSelect = (val: any, row: ListItem) => {
if (val === 'edit') {
handleEditUser(row);
handleEditCluster(row);
} else if (val === 'delete') {
handleDelete({ ...row, name: row.name });
} else if (val === 'add_worker') {
setOpen(true);
setOpenAddWorker({
open: true,
token: '${token}'
});
setCurrentData(row);
} else if (val === 'addPool') {
handleAddPool(row.provider);
@@ -308,29 +313,11 @@ const Credentials: React.FC = () => {
onCancel={handleModalCancel}
onOk={handleModalOk}
></AddCluster>
<AddPool
provider={addPoolStatus.provider}
open={addPoolStatus.open}
action={addPoolStatus.action}
title={addPoolStatus.title}
onCancel={() => {
setAddPoolStatus({
open: false,
action: PageAction.CREATE,
title: '',
provider: 'digitalocean'
});
}}
onOk={() => {
setAddPoolStatus({
open: false,
action: PageAction.CREATE,
title: '',
provider: 'digitalocean'
});
}}
></AddPool>
<AddWorker open={open} onCancel={() => setOpen(false)}></AddWorker>
<AddWorker
open={openAddWorker.open}
onCancel={() => setOpenAddWorker({ open: false, token: '' })}
token={openAddWorker.token}
></AddWorker>
<DeleteModal ref={modalRef}></DeleteModal>
</>
);
@@ -2,15 +2,18 @@ import ModalFooter from '@/components/modal-footer';
import ScrollerModal from '@/components/scroller-modal/index';
import SealInput from '@/components/seal-form/seal-input';
import { PageActionType } from '@/config/types';
import { CloseOutlined } from '@ant-design/icons';
import ContainerInstall from '@/pages/resources/components/container-install';
import { CheckCircleFilled } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Form } from 'antd';
import React from 'react';
import { Form } from 'antd';
import React, { useMemo } from 'react';
import { ProviderValueMap } from '../config';
import {
ClusterFormData as FormData,
ClusterListItem as ListItem
} from '../config/types';
import CloudProvider from './cloud-provider-form';
import RegisterClusterInner from './resiter-cluster-inner';
type AddModalProps = {
title: string;
@@ -18,7 +21,6 @@ type AddModalProps = {
open: boolean;
provider: string; // 'kubernetes' | 'custom' | 'digitalocean';
onOk: (values: FormData) => void;
data?: ListItem;
onCancel: () => void;
};
const AddCluster: React.FC<AddModalProps> = ({
@@ -27,11 +29,14 @@ const AddCluster: React.FC<AddModalProps> = ({
open,
provider,
onOk,
data,
onCancel
}) => {
const [form] = Form.useForm();
const intl = useIntl();
const [submissionStatus, setSubmissionStatus] = React.useState<{
success: boolean;
data: ListItem;
}>({ success: true, data: {} as ListItem });
const handleSubmit = () => {
form.submit();
@@ -42,56 +47,98 @@ const AddCluster: React.FC<AddModalProps> = ({
onCancel();
};
const renderAddWorkerContent = () => {
if (provider === ProviderValueMap.Custom) {
return <ContainerInstall token="${token}" />;
}
if (provider === ProviderValueMap.Kubernetes) {
return (
<RegisterClusterInner
data={submissionStatus.data}
></RegisterClusterInner>
);
}
return null;
};
const modalTitle = useMemo(() => {
if (submissionStatus.success) {
return (
<div className="flex-center">
<CheckCircleFilled
className="text-success font-size-20"
style={{ marginRight: '8px' }}
/>
<span>Cluster added! Now you can register a worker.</span>
</div>
);
}
return title;
}, [submissionStatus.success, title]);
const renderFooter = () => {
if (submissionStatus.success) {
return (
<ModalFooter
onOk={onCancel}
onCancel={onCancel}
showCancelBtn={false}
okText="Skip for Now"
okBtnProps={{
style: {
width: 'auto'
}
}}
></ModalFooter>
);
}
return <ModalFooter onOk={handleSubmit} onCancel={onCancel}></ModalFooter>;
};
return (
<ScrollerModal
title={
<div className="flex-between flex-center">
<span>{title}</span>
<Button type="text" size="small" onClick={handleCancel}>
<CloseOutlined></CloseOutlined>
</Button>
</div>
}
title={modalTitle}
open={open}
onClose={onCancel}
onCancel={handleCancel}
destroyOnClose={true}
closeIcon={false}
closeIcon={true}
maskClosable={false}
keyboard={false}
width={600}
footer={
<ModalFooter onOk={handleSubmit} onCancel={onCancel}></ModalFooter>
}
width={680}
footer={renderFooter()}
>
<Form form={form} onFinish={onOk} preserve={false}>
<Form.Item<FormData>
name="display_name"
rules={[
{
required: true,
message: intl.formatMessage(
{ id: 'common.form.rule.input' },
{
name: intl.formatMessage({ id: 'common.table.name' })
}
)
}
]}
>
<SealInput.Input
label={intl.formatMessage({ id: 'common.table.name' })}
required
></SealInput.Input>
</Form.Item>
{provider === 'digitalocean' && (
<CloudProvider provider={provider}></CloudProvider>
)}
<Form.Item<FormData> name="description" rules={[{ required: false }]}>
<SealInput.TextArea
label={intl.formatMessage({ id: 'common.table.description' })}
></SealInput.TextArea>
</Form.Item>
</Form>
{submissionStatus.success ? (
renderAddWorkerContent()
) : (
<Form form={form} onFinish={onOk} preserve={false}>
<Form.Item<FormData>
name="display_name"
rules={[
{
required: true,
message: intl.formatMessage(
{ id: 'common.form.rule.input' },
{
name: intl.formatMessage({ id: 'common.table.name' })
}
)
}
]}
>
<SealInput.Input
label={intl.formatMessage({ id: 'common.table.name' })}
required
></SealInput.Input>
</Form.Item>
{provider === 'digitalocean' && (
<CloudProvider provider={provider}></CloudProvider>
)}
<Form.Item<FormData> name="description" rules={[{ required: false }]}>
<SealInput.TextArea
label={intl.formatMessage({ id: 'common.table.description' })}
></SealInput.TextArea>
</Form.Item>
</Form>
)}
</ScrollerModal>
);
};
@@ -0,0 +1,140 @@
import GaugeChart from '@/components/echarts/gauge';
import { PageAction } from '@/config';
import { Col, Row } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { ProviderValueMap } from '../config';
import { ClusterListItem, NodePoolListItem } from '../config/types';
import AddPool from './add-pool';
import WorkerPools from './worker-pools';
const SubTitle = styled.div`
font-size: var(--font-size-middle);
font-weight: 500;
color: var(--ant-color-text);
margin-block: 24px 16px;
`;
interface ClusterDetailProps {
provider: string;
data: ClusterListItem;
show: boolean;
}
const gaugeConfig = {
radius: '100%',
progress: {
show: true,
roundCap: false,
width: 8
},
axisLine: {
roundCap: false,
lineStyle: {
width: 8,
color: [
[0.5, 'rgba(84, 204, 152, 80%)'],
[0.8, 'rgba(250, 173, 20, 80%)'],
[1, 'rgba(255, 77, 79, 80%)']
]
}
}
};
const ClusterDetail: React.FC<ClusterDetailProps> = ({ data, show }) => {
const chartHeight = 160;
const [addPoolStatus, setAddPoolStatus] = React.useState({
open: false,
action: PageAction.CREATE,
title: '',
provider: 'digitalocean'
});
// pool action handler
const handleOnAction = (action: string, record: NodePoolListItem) => {
if (action === 'edit') {
setAddPoolStatus({
open: true,
action: PageAction.CREATE,
title: 'Edit Worker Pool',
provider: data.provider
});
}
};
return (
<div>
<div className="chart-wrapper">
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<GaugeChart
title="GPU Utilization"
value={85}
height={chartHeight}
gaugeConfig={gaugeConfig}
/>
</Col>
<Col span={6}>
<GaugeChart
title="CPU Utilization"
value={50}
height={chartHeight}
gaugeConfig={gaugeConfig}
/>
</Col>
<Col span={6}>
<GaugeChart
title="RAM Utilization"
value={70}
height={chartHeight}
gaugeConfig={gaugeConfig}
/>
</Col>
<Col span={6}>
<GaugeChart
title="VRAM Utilization"
value={60}
height={chartHeight}
gaugeConfig={gaugeConfig}
/>
</Col>
</Row>
</div>
{data.provider === ProviderValueMap.DigitalOcean && (
<>
<SubTitle>Worker Pools</SubTitle>
<WorkerPools
provider={data.provider}
workerPools={data.worker_pools}
height={show ? 'auto' : 0}
onAction={handleOnAction}
/>
</>
)}
<AddPool
provider={addPoolStatus.provider}
open={addPoolStatus.open}
action={addPoolStatus.action}
title={addPoolStatus.title}
onCancel={() => {
setAddPoolStatus({
open: false,
action: PageAction.CREATE,
title: '',
provider: 'digitalocean'
});
}}
onOk={() => {
setAddPoolStatus({
open: false,
action: addPoolStatus.action,
title: '',
provider: 'digitalocean'
});
}}
></AddPool>
</div>
);
};
export default ClusterDetail;
@@ -2,13 +2,15 @@ import DropdownButtons from '@/components/drop-down-buttons';
import GaugeChart from '@/components/echarts/gauge';
import IconFont from '@/components/icon-font';
import StatusTag from '@/components/status-tag';
import ThemeTag from '@/components/tags-wrapper/theme-tag';
import Card from '@/components/templates/card';
import { PageAction } from '@/config';
import {
DeleteOutlined,
EditOutlined,
KubernetesOutlined
} from '@ant-design/icons';
import { Card as ACard, Button, Col, Row, Tag } from 'antd';
import { Card as ACard, Col, Collapse, Row } from 'antd';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import {
@@ -18,65 +20,21 @@ import {
ProviderValueMap
} from '../config';
import { ClusterListItem as ListItem, NodePoolListItem } from '../config/types';
import AddPool from './add-pool';
import RegisterCluster from './register-cluster';
import WorkerPools from './worker-pools';
const CollapseTitle = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: var(--font-size-middle);
font-weight: 500;
color: var(--ant-color-text);
height: 32px;
cursor: pointer;
`;
const Content = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
.chart-wrapper {
flex: 1;
}
`;
const CardWrapper = styled(ACard)`
text-align: center;
box-shadow: none;
flex: 1;
.ant-card {
box-shadow: none;
}
.ant-card-body {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 8px;
}
.label {
font-weight: 500;
font-size: var(--font-size-middle);
}
.value {
font-weight: 500;
color: var(--ant-color-text);
}
`;
const CardBox = styled.div`
display: flex;
gap: 16px;
flex: 0.5;
`;
const actionItems = [
{
key: 'edit',
label: 'common.button.edit',
icon: <EditOutlined />
},
{
key: 'view',
label: 'common.button.view',
icon: <EditOutlined />
},
{
key: 'add_worker',
label: 'Add Worker',
@@ -108,6 +66,60 @@ const actionItems = [
}
];
const CollapseTitle = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: var(--font-size-middle);
font-weight: 500;
color: var(--ant-color-text);
height: 32px;
cursor: pointer;
`;
const Content = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
.chart-wrapper {
flex: 1;
}
`;
const CardWrapper = styled(ACard)`
text-align: center;
box-shadow: none !important;
flex: 1;
.ant-card {
box-shadow: none;
}
.ant-card-body {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding: 6px 10px;
}
.label {
font-weight: 400;
margin-bottom: 12px;
font-size: var(--font-size-middle);
color: var(--ant-color-text-secondary);
}
.value {
font-weight: 600;
color: var(--ant-color-text);
font-size: var(--font-size-large);
}
`;
const CardBox = styled.div`
display: flex;
gap: 16px;
flex: 1;
`;
const Inner = styled.div`
display: flex;
flex-direction: column;
@@ -116,7 +128,7 @@ const Inner = styled.div`
cursor: default;
.title {
margin-bottom: 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
@@ -139,6 +151,38 @@ const Inner = styled.div`
}
`;
const CollapseWrapper = styled(Collapse)`
width: 100%;
border: none;
border-radius: 0;
border-top: 1px solid var(--ant-color-split);
background-color: var(--ant-color-fill-quaternary);
.ant-collapse-content {
border-top: 1px solid var(--ant-color-split);
}
.ant-collapse-header {
font-size: var(--font-size-middle);
padding-block: 10px !important;
.ant-collapse-expand-icon {
margin-left: 0 !important;
}
}
table .ant-table-thead tr {
background-color: transparent !important;
th {
border-bottom: 1px solid var(--ant-color-split) !important;
font-weight: 500 !important;
}
}
`;
const SubTitle = styled.div`
font-size: var(--font-size-middle);
font-weight: 500;
color: var(--ant-color-text);
margin-block: 24px 16px;
`;
interface CardProps {
data: ListItem;
onSelect?: (key: string, row: ListItem) => void;
@@ -165,14 +209,51 @@ const gaugeConfig = {
};
const CardItem: React.FC<CardProps> = (props) => {
const chartHeight = 160;
const { data, onSelect } = props;
const [show, setShow] = React.useState(false);
const [addPoolStatus, setAddPoolStatus] = React.useState({
open: false,
action: PageAction.CREATE,
title: '',
provider: 'digitalocean'
});
const [registerClusterStatus, setRegisterClusterStatus] = React.useState({
open: false
});
// cluster action handler
const handleOnSelect = (key: string) => {
if (key === 'addPool') {
setAddPoolStatus({
open: true,
action: PageAction.CREATE,
title: 'Add Worker Pool',
provider: data.provider
});
return;
}
if (key === 'register_cluster') {
setRegisterClusterStatus({
open: true
});
return;
}
onSelect?.(key, data);
};
const handleOnAction = (action: string, record: NodePoolListItem) => {};
// pool action handler
const handleOnAction = (action: string, record: NodePoolListItem) => {
if (action === 'edit') {
setAddPoolStatus({
open: true,
action: PageAction.CREATE,
title: 'Edit Worker Pool',
provider: data.provider
});
}
};
const actions = useMemo(() => {
return actionItems.filter((item) => {
@@ -184,14 +265,82 @@ const CardItem: React.FC<CardProps> = (props) => {
}, [data.provider]);
return (
<Card height={'auto'} clickable={false} ghost>
<Card
height={'auto'}
clickable={false}
ghost
footer={
<CollapseWrapper
onChange={() => setShow(!show)}
expandIconPosition="end"
expandIcon={({ isActive }) => (
<IconFont type="icon-down" rotate={isActive ? 0 : -90} />
)}
items={[
{
key: '1',
label: 'More Information',
children: (
<>
<div className="chart-wrapper">
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<GaugeChart
title="GPU Utilization"
value={85}
height={chartHeight}
gaugeConfig={gaugeConfig}
/>
</Col>
<Col span={6}>
<GaugeChart
title="CPU Utilization"
value={50}
height={chartHeight}
gaugeConfig={gaugeConfig}
/>
</Col>
<Col span={6}>
<GaugeChart
title="RAM Utilization"
value={70}
height={chartHeight}
gaugeConfig={gaugeConfig}
/>
</Col>
<Col span={6}>
<GaugeChart
title="VRAM Utilization"
value={60}
height={chartHeight}
gaugeConfig={gaugeConfig}
/>
</Col>
</Row>
</div>
{data.provider === ProviderValueMap.DigitalOcean && (
<>
<SubTitle>Worker Pools</SubTitle>
<WorkerPools
provider={data.provider}
workerPools={data.worker_pools}
height={show ? 'auto' : 0}
onAction={handleOnAction}
/>
</>
)}
</>
)
}
]}
></CollapseWrapper>
}
>
<Inner>
<div className="title">
<span className="flex-center gap-8">
<span className="text">{data.name}</span>
<Tag style={{ borderRadius: 4 }}>
{ProviderLabelMap[data.provider]}
</Tag>
<ThemeTag>{ProviderLabelMap[data.provider]}</ThemeTag>
<StatusTag
statusValue={{
status: ClusterStatus[data.status],
@@ -210,7 +359,7 @@ const CardItem: React.FC<CardProps> = (props) => {
<CardBox>
<CardWrapper bordered={false}>
<div className="label">Workers</div>
<div className="value">1</div>
<div className="value">1/1</div>
</CardWrapper>
<CardWrapper bordered={false}>
<div className="label">GPUs</div>
@@ -221,64 +370,40 @@ const CardItem: React.FC<CardProps> = (props) => {
<div className="value">2</div>
</CardWrapper>
</CardBox>
<div className="chart-wrapper">
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<GaugeChart
title="GPU Utilization"
value={85}
height={160}
gaugeConfig={gaugeConfig}
/>
</Col>
<Col span={6}>
<GaugeChart
title="CPU Utilization"
value={50}
height={160}
gaugeConfig={gaugeConfig}
/>
</Col>
<Col span={6}>
<GaugeChart
title="RAM Utilization"
value={70}
height={160}
gaugeConfig={gaugeConfig}
/>
</Col>
<Col span={6}>
<GaugeChart
title="VRAM Utilization"
value={60}
height={160}
gaugeConfig={gaugeConfig}
/>
</Col>
</Row>
</div>
</Content>
{data.provider === ProviderValueMap.DigitalOcean && (
<>
<CollapseTitle>
<Button
type="text"
size="small"
icon={<IconFont type="icon-down" rotate={show ? 0 : -90} />}
onClick={() => setShow(!show)}
>
Worker Pools
</Button>
</CollapseTitle>
<WorkerPools
provider={data.provider}
workerPools={data.worker_pools}
height={show ? 'auto' : 0}
onAction={handleOnAction}
/>
</>
)}
</Inner>
<AddPool
provider={addPoolStatus.provider}
open={addPoolStatus.open}
action={addPoolStatus.action}
title={addPoolStatus.title}
onCancel={() => {
setAddPoolStatus({
open: false,
action: PageAction.CREATE,
title: '',
provider: 'digitalocean'
});
}}
onOk={() => {
setAddPoolStatus({
open: false,
action: addPoolStatus.action,
title: '',
provider: 'digitalocean'
});
}}
></AddPool>
<RegisterCluster
title="Register Cluster"
open={registerClusterStatus.open}
data={data}
onCancel={() => {
setRegisterClusterStatus({
open: false
});
}}
></RegisterCluster>
</Card>
);
};
@@ -1,44 +1,21 @@
import ModalFooter from '@/components/modal-footer';
import ScrollerModal from '@/components/scroller-modal/index';
import SealInput from '@/components/seal-form/seal-input';
import { PageActionType } from '@/config/types';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import React from 'react';
import {
ClusterFormData as FormData,
ClusterListItem as ListItem
} from '../config/types';
import CloudProvider from './cloud-provider-form';
import K8SProvider from './k8s-provider-form';
import { ClusterListItem as ListItem } from '../config/types';
import RegisterClusterInner from './resiter-cluster-inner';
type AddModalProps = {
title: string;
action: PageActionType;
open: boolean;
provider: string; // 'kubernetes' | 'custom' | 'digitalocean';
onOk: (values: FormData) => void;
data?: ListItem;
data: ListItem;
onCancel: () => void;
};
const AddCluster: React.FC<AddModalProps> = ({
title,
action,
open,
provider,
onOk,
data,
onCancel
}) => {
const [form] = Form.useForm();
const intl = useIntl();
const handleSubmit = () => {
form.submit();
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
@@ -52,42 +29,9 @@ const AddCluster: React.FC<AddModalProps> = ({
maskClosable={false}
keyboard={false}
width={600}
footer={
<ModalFooter onOk={handleSubmit} onCancel={onCancel}></ModalFooter>
}
footer={false}
>
<Form form={form} onFinish={onOk} preserve={false}>
<Form.Item<FormData>
name="display_name"
rules={[
{
required: true,
message: intl.formatMessage(
{ id: 'common.form.rule.input' },
{
name: intl.formatMessage({ id: 'common.table.name' })
}
)
}
]}
>
<SealInput.Input
label={intl.formatMessage({ id: 'common.table.name' })}
required
></SealInput.Input>
</Form.Item>
{provider === 'digitalocean' && (
<CloudProvider provider={provider}></CloudProvider>
)}
{provider === 'kubernetes' && (
<K8SProvider provider={provider}></K8SProvider>
)}
<Form.Item<FormData> name="description" rules={[{ required: false }]}>
<SealInput.TextArea
label={intl.formatMessage({ id: 'common.table.description' })}
></SealInput.TextArea>
</Form.Item>
</Form>
<RegisterClusterInner data={data} />
</ScrollerModal>
);
};
@@ -0,0 +1,53 @@
import HighlightCode from '@/components/highlight-code';
import React, { useEffect } from 'react';
import { queryClusterToken } from '../apis';
import { generateRegisterCommand } from '../config';
import { ClusterListItem as ListItem } from '../config/types';
type AddModalProps = {
data: ListItem;
};
const AddCluster: React.FC<AddModalProps> = ({ data }) => {
const [code, setCode] = React.useState<string>('');
const getToken = async () => {
const res = await queryClusterToken(data?.id);
return res.data?.token || '';
};
const getCode = async () => {
try {
const token = await getToken();
const command = generateRegisterCommand({
server: window.location.origin,
clusterId: data?.id || 0,
registrationToken: token
});
setCode(command);
} catch (error) {
setCode(
generateRegisterCommand({
server: window.location.origin,
clusterId: data?.id || 0,
registrationToken: '{token}'
})
);
}
};
useEffect(() => {
getCode();
}, []);
return (
<div>
<HighlightCode
theme="dark"
code={code.replace(/\\/g, '')}
copyValue={code}
lang="bash"
></HighlightCode>
</div>
);
};
export default AddCluster;
@@ -71,9 +71,9 @@ const WorkerPools: React.FC<WorkerPoolsProps> = ({
const columns = [
{
title: 'Type',
dataIndex: 'type',
key: 'type'
title: 'Instance Type',
dataIndex: 'instance_type',
key: 'instance_type'
},
{
title: 'Replicas',
@@ -126,7 +126,7 @@ const WorkerPools: React.FC<WorkerPoolsProps> = ({
const mockData = Array.from({ length: 3 }, (_, index) => ({
id: index + 1,
key: index,
type: `Type ${index + 1}`,
instance_type: `Type ${index + 1}`,
replicas: Math.floor(Math.random() * 10) + 1,
batchSize: Math.floor(Math.random() * 100) + 1,
gpu: `NVIDIA 4090`,
@@ -59,3 +59,12 @@ export const ProviderLabelMap = {
[ProviderValueMap.DigitalOcean]: 'Digital Ocean',
[ProviderValueMap.Custom]: 'Custom'
};
export const generateRegisterCommand = (params: {
server: string;
clusterId: number;
registrationToken: string;
}) => {
return `curl -k -L '${params.server}/v2/clusters/${params.clusterId}/manifests' \\
--header 'Authorization: Bearer ${params.registrationToken}'`;
};
+2 -2
View File
@@ -20,8 +20,8 @@ const renderCardItem = (data: {
className={styles['card-body']}
>
<div className={styles.content}>
<div className="label font-500">{label}</div>
<div className="value font-500">{value}</div>
<div className="label text-secondary">{label}</div>
<div className="value font-600 font-size-16">{value}</div>
</div>
</Card>
);
@@ -12,7 +12,7 @@ const GGUFResult: React.FC = () => {
/>
}
title={false}
subTitle="GGUF model is not supported yet."
subTitle="GGUF model is not supported."
/>
);
};
+2 -49
View File
@@ -1,64 +1,17 @@
import ScrollerModal from '@/components/scroller-modal';
import {
AppleOutlined,
LinuxOutlined,
WindowsOutlined
} from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { TabsProps } from 'antd';
import React from 'react';
import MacOS from './add-worker-macos';
import ContainerInstall from './container-install';
type ViewModalProps = {
open: boolean;
token: string;
onCancel: () => void;
};
const AddWorker: React.FC<ViewModalProps> = (props) => {
const { open, onCancel } = props || {};
const { open, onCancel, token = '${token}' } = props || {};
const intl = useIntl();
const [token, setToken] = React.useState('');
const [activeKey, setActiveKey] = React.useState('container');
const items: TabsProps['items'] = [
{
key: 'container',
label: 'Linux',
icon: <LinuxOutlined />,
children: <ContainerInstall token={token} />
},
{
key: 'macos',
label: 'macOS',
icon: <AppleOutlined />,
children: (
<MacOS
token={token}
platform={{
os: 'macOS',
downloadurl: 'https://gpustack.ai/',
supportVersions: 'resource.register.maos.support'
}}
/>
)
},
{
key: 'windows',
label: 'Windows',
icon: <WindowsOutlined />,
children: (
<MacOS
token={token}
platform={{
os: 'Windows',
downloadurl: 'https://gpustack.ai/',
supportVersions: 'resource.register.windows.support'
}}
/>
)
}
];
return (
<ScrollerModal
@@ -42,7 +42,7 @@ const AddWorker: React.FC<ViewModalProps> = (props) => {
return commandCode?.registerWorker({
server: origin,
tag: tag,
token: '${token}',
token: props.token || '${token}',
workerip: '${workerip}'
});
}, [versionInfo, activeKey, props.token, npuKey]);
@@ -53,38 +53,8 @@ const AddWorker: React.FC<ViewModalProps> = (props) => {
return (
<div className="container-install">
<ul className="notes">
<li>
{intl.formatMessage(
{ id: 'resources.worker.current.version' },
{ version: versionInfo.version }
)}
</li>
<li>
<span
dangerouslySetInnerHTML={{
__html: intl.formatMessage({
id: 'resources.worker.driver.install'
})
}}
></span>
</li>
</ul>
<h3 className="font-size-14 font-600">
1.{' '}
<span
dangerouslySetInnerHTML={{
__html: intl.formatMessage({ id: 'resources.worker.add.step1' })
}}
></span>
</h3>
<HighlightCode
code={addWorkerGuide.container.getToken}
theme="dark"
lang="bash"
></HighlightCode>
<h3 className="m-t-10 font-size-14 font-600">
2. {intl.formatMessage({ id: 'resources.worker.add.step2' })}{' '}
1. {intl.formatMessage({ id: 'resources.worker.add.step2' })}{' '}
<span
className="font-size-12"
style={{ color: 'var(--ant-color-text-tertiary)' }}
@@ -146,7 +116,7 @@ const AddWorker: React.FC<ViewModalProps> = (props) => {
></HighlightCode>
)}
<h3 className="m-b-0 m-t-10 font-size-14 font-600">
3. {intl.formatMessage({ id: 'resources.worker.add.step3' })}
2. {intl.formatMessage({ id: 'resources.worker.add.step3' })}
</h3>
</div>
);
+19 -16
View File
@@ -22,14 +22,14 @@ export const addWorkerGuide: Record<string, any> = {
mac: {
getToken: 'cat /var/lib/gpustack/token',
registerWorker(params: { server: string; token: string }) {
return `curl -sfL https://get.gpustack.ai | sh -s - --server-url ${params.server} --token ${params.token}`;
return `curl -sfL https://get.gpustack.ai | sh -s - --server-url ${params.server} --registration ${params.token}`;
}
},
win: {
getToken:
'Get-Content -Path (Join-Path -Path $env:APPDATA -ChildPath "gpustack\\token") -Raw',
registerWorker(params: { server: string; token: string }) {
return `Invoke-Expression "& { $((Invoke-WebRequest -Uri 'https://get.gpustack.ai' -UseBasicParsing).Content) } --server-url '${params.server}' --token '${params.token}'"`;
return `Invoke-Expression "& { $((Invoke-WebRequest -Uri 'https://get.gpustack.ai' -UseBasicParsing).Content) } --server-url '${params.server}' --registration '${params.token}'"`;
}
},
cuda: {
@@ -41,11 +41,14 @@ export const addWorkerGuide: Record<string, any> = {
token: string;
workerip: string;
}) {
return `docker run -d \\
--net=host \\
-v /var/lib/gpustack:/var/lib/gpustack \\
--privileged gpustack/gpustack:xxxx \\
--registration ${params.token}`;
return `docker run -d --name gpustack \\
--restart=unless-stopped \\
--gpus all \\
--network=host \\
--ipc=host \\
-v gpustack-data:/var/lib/gpustack \\
gpustack/gpustack:${params.tag} \\
--server-url ${params.server} --registration ${params.token} --worker-ip ${params.workerip}`;
}
},
npu: {
@@ -73,7 +76,7 @@ export const addWorkerGuide: Record<string, any> = {
--ipc=host \\
-v gpustack-data:/var/lib/gpustack \\
gpustack/gpustack:${params.tag} \\
--server-url ${params.server} --token ${params.token} --worker-ip ${params.workerip}`;
--server-url ${params.server} --registration ${params.token} --worker-ip ${params.workerip}`;
}
},
npu310p: {
@@ -101,7 +104,7 @@ export const addWorkerGuide: Record<string, any> = {
--ipc=host \\
-v gpustack-data:/var/lib/gpustack \\
gpustack/gpustack:${params.tag}-310p \\
--server-url ${params.server} --token ${params.token} --worker-ip ${params.workerip}`;
--server-url ${params.server} --registration ${params.token} --worker-ip ${params.workerip}`;
}
},
musa: {
@@ -119,7 +122,7 @@ export const addWorkerGuide: Record<string, any> = {
--ipc=host \\
-v gpustack-data:/var/lib/gpustack \\
gpustack/gpustack:${params.tag} \\
--server-url ${params.server} --token ${params.token} --worker-ip ${params.workerip}`;
--server-url ${params.server} --registration ${params.token} --worker-ip ${params.workerip}`;
}
},
cpu: {
@@ -136,7 +139,7 @@ export const addWorkerGuide: Record<string, any> = {
--network=host \\
-v gpustack-data:/var/lib/gpustack \\
gpustack/gpustack:${params.tag} \\
--server-url ${params.server} --token ${params.token} --worker-ip ${params.workerip}`;
--server-url ${params.server} --registration ${params.token} --worker-ip ${params.workerip}`;
}
},
rocm: {
@@ -157,7 +160,7 @@ export const addWorkerGuide: Record<string, any> = {
--security-opt seccomp=unconfined \\
-v gpustack-data:/var/lib/gpustack \\
gpustack/gpustack:${params.tag} \\
--server-url ${params.server} --token ${params.token} --worker-ip ${params.workerip}`;
--server-url ${params.server} --registration ${params.token} --worker-ip ${params.workerip}`;
}
},
dcu: {
@@ -180,7 +183,7 @@ export const addWorkerGuide: Record<string, any> = {
--security-opt seccomp=unconfined \\
-v gpustack-data:/var/lib/gpustack \\
gpustack/gpustack:${params.tag} \\
--server-url ${params.server} --token ${params.token} --worker-ip ${params.workerip}`;
--server-url ${params.server} --registration ${params.token} --worker-ip ${params.workerip}`;
}
},
corex: {
@@ -201,7 +204,7 @@ export const addWorkerGuide: Record<string, any> = {
--ipc=host \\
-v gpustack-data:/var/lib/gpustack \\
gpustack/gpustack:${params.tag} \\
--server-url ${params.server} --token ${params.token} --worker-ip ${params.workerip}`;
--server-url ${params.server} --registration ${params.token} --worker-ip ${params.workerip}`;
}
},
container: {
@@ -216,8 +219,8 @@ export const containerInstallOptions = [
{ label: 'Ascend CANN', value: 'npu' },
{ label: 'Hygon DTK', value: 'dcu' },
{ label: 'Moore Threads MUSA', value: 'musa' },
{ label: 'Iluvatar Corex', value: 'corex' },
{ label: 'CPU', value: 'cpu' }
{ label: 'Iluvatar Corex', value: 'corex' }
// { label: 'CPU', value: 'cpu' }
];
export const ModelfileStateMap = {